aboutsummaryrefslogtreecommitdiff
path: root/sfm.sh
diff options
context:
space:
mode:
authoremmett1 <me@emmett1.my>2026-06-16 07:43:51 +0800
committeremmett1 <me@emmett1.my>2026-06-16 07:43:51 +0800
commit40e5d2815cc6cdff95751af9377ebe3ff33daf81 (patch)
tree9791e4718891449e3818ce8e01286622263eeaa8 /sfm.sh
parent0504dec9c7448aac74132054217e7bc3c437b424 (diff)
downloadsfm-40e5d2815cc6cdff95751af9377ebe3ff33daf81.tar.gz
sfm-40e5d2815cc6cdff95751af9377ebe3ff33daf81.zip
added cpp version, sh version as backupv1.0
Diffstat (limited to 'sfm.sh')
-rwxr-xr-xsfm.sh1870
1 files changed, 1870 insertions, 0 deletions
diff --git a/sfm.sh b/sfm.sh
new file mode 100755
index 0000000..032b6e5
--- /dev/null
+++ b/sfm.sh
@@ -0,0 +1,1870 @@
+#!/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')
+
+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 ---
+if [ -n "$1" ]; then
+ CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; }
+else
+ CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/"
+fi
+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
+SHOW_PREVIEW=0 # 1 = show preview pane on right
+TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash"
+mkdir -p "$TRASH_DIR"
+PREV_CWD="" # last visited directory, for jumping back
+
+# normalise CWD: resolve to absolute canonical path, no double slashes
+norm_cwd() {
+ CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/"
+ # squeeze any double slashes (some systems return // for //<path>)
+ CWD=$(printf '%s' "$CWD" | tr -s '/')
+}
+
+# safely join CWD with a name, avoiding double slash at root
+joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; }
+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
+
+ # hidden dirs first
+ if [ "$SHOW_HIDDEN" = "1" ]; then
+ for d in "$CWD"/.*/; do
+ [ -d "$d" ] || continue
+ [ -L "${d%/}" ] && continue
+ name="${d%/}"; name="${name##*/}"
+ [ "$name" = "." ] || [ "$name" = ".." ] && continue
+ [ "$name" = ".*" ] && continue
+ ENTRIES="$ENTRIES
+${name}/"
+ COUNT=$((COUNT + 1))
+ done
+ fi
+
+ # regular dirs
+ for d in "$CWD"/*/; do
+ [ -d "$d" ] || continue
+ [ -L "${d%/}" ] && continue
+ name="${d%/}"; name="${name##*/}"
+ [ "$name" = "*" ] && continue
+ case "$name" in .*) continue ;; esac
+ ENTRIES="$ENTRIES
+${name}/"
+ COUNT=$((COUNT + 1))
+ done
+
+ # hidden files first
+ 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
+
+ # regular files
+ 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
+
+ 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"
+ else
+ ENTRIES=""
+ COUNT=0
+ _rest="$ALL_ENTRIES"
+ while [ -n "$_rest" ]; do
+ _line="${_rest%%
+*}"; _next="${_rest#*
+}"
+ [ "$_next" = "$_rest" ] && _next=""
+ _rest="$_next"
+ [ -z "$_line" ] && continue
+ case "$_line" in
+ *"$FILTER"*)
+ if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
+ else ENTRIES="$ENTRIES
+$_line"; fi
+ COUNT=$((COUNT + 1)) ;;
+ esac
+ done
+ fi
+ # write to tmp file for O(1) line access in get_entry
+ printf '%s\n' "$ENTRIES" > /tmp/_sfm_list_$$
+}
+
+get_entry() {
+ sed -n "$(($1 + 1))p" /tmp/_sfm_list_$$
+}
+
+# find index of entry by name (0-based), returns -1 if not found
+find_entry() {
+ _fe_n=$(grep -nFx "$1" /tmp/_sfm_list_$$ 2>/dev/null | head -1 | cut -d: -f1)
+ if [ -n "$_fe_n" ]; then
+ printf '%d' $((_fe_n - 1))
+ else
+ printf '%d' -1
+ fi
+}
+
+# is entry name in SELECTED list?
+is_selected() {
+ printf '%s\n' "$SELECTED" | grep -Fxq "$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="${BLUE}${BOLD}" ;;
+ *@)
+ _name="${entry%@}"
+ if [ -e "${CWD}/${_name}" ]; then
+ colour="${CYAN}${BOLD}"
+ else
+ colour="${RED}${BOLD}"
+ fi ;;
+ *)
+ _fc="${CWD}/${entry}"
+ if [ -x "$_fc" ]; then colour="${GREEN}${BOLD}"
+ elif [ -c "$_fc" ]; then colour="${MAGENTA}${BOLD}"
+ elif [ -b "$_fc" ]; then colour="${MAGENTA}${BOLD}"
+ elif [ ! -r "$_fc" ] || [ ! -w "$_fc" ]; then colour="${RED}${BOLD}"
+ else colour="${WHITE}"
+ fi ;;
+ 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)
+ # parse ls output with parameter expansion — no awk fork
+ set -- $_info
+ _sz=$5; _dt="$6 $7"
+ _detail=$(printf ' %6s %-6s' "$_sz" "$_dt")
+ set --
+ fi
+
+ # layout: marker(1) + name + spaces + detail
+ _dlen=${#_detail}
+ maxw=$((_cols - 2 - _dlen))
+ if [ "${#display}" -gt "$maxw" ]; then
+ _trunc=$((maxw - 3))
+ while [ "${#display}" -gt "$_trunc" ]; do
+ display="${display%?}"
+ done
+ 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_preview() {
+ _rows=$1; _cols=$2; _px=$3; _pw=$4
+ entry=$(get_entry "$SEL")
+ # clear preview area first
+ _r=2
+ while [ "$_r" -le $((_rows - 1)) ]; do
+ goto "$_r" "$_px"
+ printf '\033[K'
+ _r=$((_r + 1))
+ done
+ [ -z "$entry" ] && return
+ _path=$(joinpath "${entry%/}"); _path="${_path%@}"
+
+ # draw vertical divider
+ _r=2
+ while [ "$_r" -le $((_rows - 1)) ]; do
+ goto "$_r" $((_px - 1))
+ printf '%s|%s' "${CYAN}" "${RESET}"
+ _r=$((_r + 1))
+ done
+
+ case "$entry" in
+ */)
+ # directory: list contents
+ _max_lines=$((_rows - 3))
+ _pr=2
+ for _de in "$_path"/*/; do
+ [ "$_pr" -gt $((_rows - 1)) ] && break
+ [ -d "$_de" ] || continue
+ _dn="${_de%/}"; _dn="${_dn##*/}"
+ [ "$_dn" = "*" ] && continue
+ _dl=" ${_dn}/"
+ [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~"
+ goto "$_pr" "$_px"
+ printf '%s%s%s' "${BLUE}${BOLD}" "$_dl" "${RESET}"
+ _pr=$((_pr + 1))
+ done
+ for _fe in "$_path"/*; do
+ [ "$_pr" -gt $((_rows - 1)) ] && break
+ [ -e "$_fe" ] || continue
+ [ -d "$_fe" ] && continue
+ _fn="${_fe##*/}"
+ [ "$_fn" = "*" ] && continue
+ _fl=" ${_fn}"
+ [ "${#_fl}" -gt "$((_pw - 1))" ] && _fl="$(printf '%s' "$_fl" | cut -c1-$((_pw-2)))~"
+ goto "$_pr" "$_px"
+ printf '%s%s%s' "${WHITE}" "$_fl" "${RESET}"
+ _pr=$((_pr + 1))
+ done
+ if [ "$_pr" -eq 2 ]; then
+ goto 2 "$_px"
+ printf '%s(empty)%s' "${WHITE}" "${RESET}"
+ fi
+ ;;
+ *@)
+ # symlink
+ _tgt=$(readlink "$_path" 2>/dev/null || echo "?")
+ goto 2 "$_px"
+ printf '%s[symlink]%s' "${MAGENTA}${BOLD}" "${RESET}"
+ goto 3 "$_px"
+ printf '%s-> %s%s' "${WHITE}" "${_tgt}" "${RESET}"
+ ;;
+ *)
+ # detect if text via extension or mime
+ _ext="${entry##*.}"
+ _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')
+ _is_text=0
+ case "$_ext" in
+ txt|md|markdown|rst|log|conf|cfg|ini|toml|yaml|yml|\
+ sh|bash|zsh|py|rb|pl|lua|js|ts|json|xml|html|htm|\
+ css|c|h|cpp|rs|go|java|php|r|sql|vim|env|gitignore|\
+ diff|patch|csv|tsv|lock|mod|sum) _is_text=1 ;;
+ esac
+ if [ "$_is_text" = "0" ] && command -v file >/dev/null 2>&1; then
+ case "$(file --mime-type -b "$_path" 2>/dev/null)" in
+ text/*) _is_text=1 ;;
+ esac
+ fi
+ if [ "$_is_text" = "1" ]; then
+ _max_lines=$((_rows - 3))
+ _line_n=0
+ while IFS= read -r _line && [ "$_line_n" -lt "$_max_lines" ]; do
+ # truncate to preview width
+ if [ "${#_line}" -gt "$((_pw - 1))" ]; then
+ _line=$(printf '%s' "$_line" | cut -c1-$((_pw - 2)))
+ _line="${_line}~"
+ fi
+ goto "$((_line_n + 2))" "$_px"
+ printf '%s%s%s' "${WHITE}" "$_line" "${RESET}"
+ _line_n=$((_line_n + 1))
+ done < "$_path"
+ if [ "$_line_n" -eq 0 ]; then
+ goto 2 "$_px"
+ printf '%s(empty file)%s' "${WHITE}" "${RESET}"
+ fi
+ else
+ # binary / unknown
+ goto 2 "$_px"
+ _sz=$(ls -lh "$_path" 2>/dev/null | awk '{print $5}')
+ printf '%s[binary] %s%s' "${YELLOW}" "${_sz}" "${RESET}"
+ fi
+ ;;
+ esac
+}
+
+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=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((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=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((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=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((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
+
+ # split layout when preview enabled
+ if [ "$SHOW_PREVIEW" = "1" ]; then
+ LIST_COLS=$((COLS / 2))
+ PREV_COL=$((LIST_COLS + 2))
+ PREV_WIDTH=$((COLS - LIST_COLS - 2))
+ else
+ LIST_COLS=$COLS
+ fi
+
+ # 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
+ 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" "$LIST_COLS"
+ row=$((row + 1))
+ idx=$((idx + 1))
+ done
+ # show (empty) if directory has no entries
+ if [ "$COUNT" -eq 0 ]; then
+ goto 2 1
+ printf '%s (empty)%s%s' "${WHITE}" "${ERASE_LINE}" "${RESET}"
+ row=3
+ fi
+ # blank leftover rows
+ while [ "$row" -le $((LIST_ROWS + 1)) ]; do
+ goto "$row" 1
+ printf '%s' "${ERASE_LINE}"
+ row=$((row + 1))
+ done
+
+ [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"
+
+ draw_botbar "$ROWS" "$COLS"
+ NEED_FULL_REDRAW=0
+ else
+ # --- fast path ---
+ 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 "$LIST_COLS"
+ fi
+ if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then
+ render_row "$new_row" "$SEL" 1 "$LIST_COLS"
+ fi
+
+ [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"
+
+ 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
+ IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2=""
+ if [ -z "$_k2" ]; then
+ printf '\033'; return
+ fi
+ IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3=""
+ # if sequence ends with ~, it may have more chars — read one more
+ case "${_k2}${_k3}" in
+ '['[0-9])
+ IFS= read -r -n1 -t 0.1 _k4 2>/dev/null || _k4=""
+ key="${key}${_k2}${_k3}${_k4}"
+ ;;
+ *)
+ key="${key}${_k2}${_k3}"
+ ;;
+ esac
+ 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"
+
+ # use user opener script if it exists and is executable
+ _opener="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/opener"
+ if [ -x "$_opener" ]; then
+ "$_opener" "$_f"
+ return
+ fi
+
+ _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="${_init:-}"
+ _init=""
+ show_cursor
+ printf '%s' "$READ_LINE"
+ 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 File name: %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=46
+ _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_row 14 " P toggle preview pane"
+ help_sep 15
+ help_row 16 " space toggle multi-select"
+ help_row 17 " a select all / deselect all"
+ help_row 18 " y yank/copy (works on selection)"
+ help_row 19 " x cut (works on selection)"
+ help_row 20 " p paste"
+ help_row 21 " d delete (works on selection)"
+ help_sep 22
+ help_row 23 " r rename R refresh"
+ help_row 24 " m make directory"
+ help_row 25 " n new file"
+ help_row 26 " u trash file (safe delete)"
+ help_row 27 " U open trash directory"
+ help_row 28 " ! drop to shell in CWD"
+ help_row 29 " o open with custom program"
+ help_row 30 " + chmod +x (make executable)"
+ help_row 31 " - chmod -x (remove executable)"
+ help_sep 32
+ help_row 33 " b bookmark current dir"
+ help_row 34 " B open bookmark picker"
+ help_row 35 " c copy path to clipboard"
+ help_row 36 " ~ go to home directory"
+ help_row 37 " \` jump to previous directory"
+ help_row 38 " : jump to path"
+ help_row 39 " f find files recursively"
+ help_sep 40
+ help_row 41 " q quit"
+ help_row 42 " ? this help"
+ help_row 43 ""
+ help_row 44 " 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_toggle_preview() {
+ if [ "$SHOW_PREVIEW" = "0" ]; then
+ SHOW_PREVIEW=1; INFO_MSG="preview on"
+ else
+ SHOW_PREVIEW=0; INFO_MSG="preview off"
+ fi
+ NEED_FULL_REDRAW=1
+}
+
+do_find() {
+ goto "$(term_rows)" 1
+ printf '%s%s find (recursive): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
+ if ! read_line || [ -z "$READ_LINE" ]; then
+ NEED_FULL_REDRAW=1; draw; return
+ fi
+ _query="$READ_LINE"
+
+ # run find and collect results into a tmp file
+ _tmp=/tmp/_sfm_find_$$
+ find "$CWD" -name "*${_query}*" 2>/dev/null | sort > "$_tmp"
+ _total=$(wc -l < "$_tmp" | tr -d '[:space:]')
+
+ if [ "$_total" -eq 0 ]; then
+ INFO_MSG="no results for: ${_query}"
+ NEED_FULL_REDRAW=1; return
+ fi
+
+ # show results in an interactive picker
+ ROWS=$(term_rows); COLS=$(term_cols)
+ _pw=$(( COLS - 4 )); [ "$_pw" -gt 80 ] && _pw=80; [ "$_pw" -lt 30 ] && _pw=30
+ _ph=$(( ROWS - 4 )); [ "$_ph" -lt 5 ] && _ph=5
+ _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 ' ' '-')
+ _vis=$((_ph - 3)) # visible result rows
+
+ _fsel=1
+ _foff=1 # scroll offset (1-based)
+
+ while true; do
+ # draw box
+ goto "$_py" "$_px"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+
+ # title
+ goto "$((_py+1))" "$_px"
+ _title=" find: ${_query} (${_total} results)"
+ [ "${#_title}" -gt "$_iw" ] && _title=$(printf '%s' "$_title" | cut -c1-"$_iw")
+ _tpl=$((_iw - ${#_title})); _tp=$(printf '%*s' "$_tpl" '')
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_title" "$_tp" "${BOLD}${CYAN}" "${RESET}"
+
+ goto "$((_py+2))" "$_px"
+ printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+
+ # results
+ _ri=0
+ while [ "$_ri" -lt "$_vis" ]; do
+ _ridx=$((_foff + _ri))
+ goto "$((_py+3+_ri))" "$_px"
+ if [ "$_ridx" -le "$_total" ]; then
+ _rline=$(awk -v n="$_ridx" 'NR==n{print;exit}' "$_tmp")
+ # strip CWD prefix for display
+ _rdisplay="${_rline#$CWD/}"
+ [ "${#_rdisplay}" -gt "$_iw" ] && \
+ _rdisplay="...$(printf '%s' "$_rdisplay" | rev | cut -c1-$((_iw-4)) | rev)"
+ _rpl=$((_iw - ${#_rdisplay})); _rp=$(printf '%*s' "$_rpl" '')
+ if [ "$_ridx" -eq "$_fsel" ]; then
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_rdisplay" "$_rp" "${RESET}${BOLD}${CYAN}" "${RESET}"
+ else
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_rdisplay" "$_rp" "${BOLD}${CYAN}" "${RESET}"
+ fi
+ else
+ _ep=$(printf '%*s' "$_iw" '')
+ printf '%s|%s|%s' "${BOLD}${CYAN}" "$_ep" "${RESET}"
+ fi
+ _ri=$((_ri+1))
+ done
+
+ goto "$((_py+_ph))" "$_px"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+
+ # read key
+ IFS= read -r -n1 _fk 2>/dev/null || IFS= read -r _fk
+ case "$_fk" in
+ j) _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total
+ [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;;
+ k) _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1
+ [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;;
+ "$(printf '\033')")
+ IFS= read -r -n2 -t 0.1 _fseq 2>/dev/null || _fseq=""
+ case "$_fseq" in
+ '[A') _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1
+ [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;;
+ '[B') _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total
+ [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;;
+ *) NEED_FULL_REDRAW=1; return ;; # esc — close
+ esac ;;
+ "$(printf '\n')"|\
+ "$(printf '\r')"|\
+ l)
+ _chosen=$(awk -v n="$_fsel" 'NR==n{print;exit}' "$_tmp")
+ if [ -d "$_chosen" ]; then
+ PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
+ elif [ -e "$_chosen" ]; then
+ PREV_CWD="$CWD"; CWD=$(dirname "$_chosen"); norm_cwd
+ fi
+ FILTER=""; SELECTED=""
+ SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
+ load_entries
+ # try to highlight the found file
+ _fname="${_chosen##*/}"
+ [ -d "$_chosen" ] && _fname="${_fname}/"
+ _fidx=$(find_entry "$_fname")
+ [ "$_fidx" -ge 0 ] 2>/dev/null && SEL=$_fidx
+ return ;;
+ q|Q|h) NEED_FULL_REDRAW=1; return ;;
+ esac
+ done
+}
+
+do_jump_path() {
+ goto "$(term_rows)" 1
+ printf '%s%s jump to: %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
+ if read_line && [ -n "$READ_LINE" ]; then
+ # expand ~ manually
+ case "$READ_LINE" in
+ "~"*) READ_LINE="${HOME}${READ_LINE#\~}" ;;
+ esac
+ if [ -d "$READ_LINE" ]; then
+ PREV_CWD="$CWD"
+ CWD="$READ_LINE"; norm_cwd
+ FILTER=""; SELECTED=""
+ SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
+ load_entries
+ else
+ INFO_MSG="not found: ${READ_LINE}"
+ NEED_FULL_REDRAW=1; draw
+ fi
+ else
+ NEED_FULL_REDRAW=1; draw
+ fi
+}
+
+do_go_back() {
+ [ "$CWD" = "/" ] && return
+ FILTER=""
+ SELECTED=""
+ PREV_CWD="$CWD"
+ LAST_CHILD="${CWD##*/}/" # name of current dir with trailing slash
+ CWD=$(cd "$(dirname "$CWD")" && pwd)
+ 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=$(joinpath "${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); norm_cwd
+ SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
+ load_entries
+ ;;
+ *@)
+ _name="${entry%@}"
+ _target=$(joinpath "$_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); norm_cwd
+ 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
+}
+
+# --- confirm overlay (yes/no popup) ---
+confirm_overlay() {
+ _co_rows=$(term_rows); _co_cols=$(term_cols)
+ _co_yes=" Yes "
+ _co_no=" No "
+ _co_gap=" "
+ _co_content="${_co_yes}${_co_gap}${_co_no}"
+
+ # size box to fit prompt or buttons, whichever is wider
+ _co_w=$(( ${#_prompt} + 4 ))
+ [ "$_co_w" -lt $(( ${#_co_content} + 4 )) ] && _co_w=$(( ${#_co_content} + 4 ))
+ [ "$_co_w" -gt "$_co_cols" ] && _co_w=$((_co_cols - 2))
+ _co_iw=$((_co_w - 2))
+ _co_hl=$(printf '%*s' "$_co_iw" '' | tr ' ' '-')
+
+ _co_x=$(( (_co_cols - _co_w) / 2 )); [ "$_co_x" -lt 0 ] && _co_x=0
+ _co_y=$(( (_co_rows - 5) / 2 )); [ "$_co_y" -lt 1 ] && _co_y=1
+ _co_sel=2 # 1=Yes, 2=No (default No for safety)
+
+ while true; do
+ # draw box top
+ goto "$_co_y" "$_co_x"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}"
+ # prompt line
+ goto "$((_co_y+1))" "$_co_x"
+ _co_pl=$((_co_iw - ${#_prompt}))
+ [ "$_co_pl" -lt 0 ] && _co_pl=0
+ _co_pad=$(printf '%*s' "$_co_pl" '')
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_prompt" "$_co_pad" "${BOLD}${CYAN}" "${RESET}"
+ # separator
+ goto "$((_co_y+2))" "$_co_x"
+ printf '%s|%s|%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}"
+ # yes/no buttons
+ goto "$((_co_y+3))" "$_co_x"
+ _co_cpad=$(( (_co_iw - ${#_co_content}) / 2 ))
+ [ "$_co_cpad" -lt 0 ] && _co_cpad=0
+ _co_left=$(printf '%*s' "$_co_cpad" '')
+ _co_cpad=$(( _co_iw - ${#_co_content} - _co_cpad ))
+ [ "$_co_cpad" -lt 0 ] && _co_cpad=0
+ _co_right=$(printf '%*s' "$_co_cpad" '')
+ printf '%s|%s%s' "${BOLD}${CYAN}" "${RESET}" "$_co_left"
+ if [ "$_co_sel" -eq 1 ]; then
+ printf '%s%s%s' "${REV}${GREEN}${BOLD}" "$_co_yes" "${RESET}"
+ else
+ printf '%s' "$_co_yes"
+ fi
+ printf '%s' "$_co_gap"
+ if [ "$_co_sel" -eq 2 ]; then
+ printf '%s%s%s' "${REV}${RED}${BOLD}" "$_co_no" "${RESET}"
+ else
+ printf '%s' "$_co_no"
+ fi
+ printf '%s%s|%s' "$_co_right" "${BOLD}${CYAN}" "${RESET}"
+ # box bottom
+ goto "$((_co_y+4))" "$_co_x"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}"
+
+ # read key
+ IFS= read -r -n1 _co_ch 2>/dev/null || IFS= read -r _co_ch
+ case "$_co_ch" in
+ "$(printf '\033')")
+ IFS= read -r -n2 -t 0.1 _co_seq 2>/dev/null || _co_seq=""
+ case "$_co_seq" in
+ '[D'|'[C') # left/right arrow
+ if [ "$_co_sel" -eq 1 ]; then _co_sel=2; else _co_sel=1; fi ;;
+ *) return 1 ;; # bare esc or unrecognised sequence
+ esac ;;
+ h) _co_sel=1 ;;
+ l) _co_sel=2 ;;
+ j) _co_sel=2 ;;
+ k) _co_sel=1 ;;
+ y|Y) return 0 ;;
+ n|N) return 1 ;;
+ "$(printf '\n')"|"$(printf '\r')")
+ if [ "$_co_sel" -eq 1 ]; then return 0; else return 1; fi ;;
+ esac
+ done
+}
+
+do_delete() {
+ INFO_MSG=""
+ _c=$(count_selected)
+ if [ "$_c" -gt 0 ]; then
+ # --- bulk delete selected ---
+ _prompt="Delete ${_c} selected items?"
+ confirm_overlay; _yes=$?
+ if [ "$_yes" -eq 0 ]; then
+ _rest="$SELECTED"
+ while [ -n "$_rest" ]; do
+ _line="${_rest%%
+*}"
+ _next="${_rest#*
+}"
+ [ "$_next" = "$_rest" ] && _next=""
+ _rest="$_next"
+ [ -z "$_line" ] && continue
+ _name="${_line%@}"; _name="${_name%/}"
+ _t="${CWD}/${_name}"
+ 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
+ else
+ NEED_FULL_REDRAW=1
+ fi
+ else
+ # --- single delete ---
+ entry=$(get_entry "$SEL")
+ _name="${entry%@}"; _name="${_name%/}"
+ target="${CWD}/${_name}"
+ _prompt="Delete \"${entry}\"?"
+ confirm_overlay; _yes=$?
+ if [ "$_yes" -ne 0 ]; then
+ NEED_FULL_REDRAW=1; return
+ fi
+ if [ -d "$target" ]; then
+ if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then
+ _prompt="\"${entry}\" not empty. Delete ALL?"
+ confirm_overlay; _yes2=$?
+ if [ "$_yes2" -ne 0 ]; then
+ NEED_FULL_REDRAW=1; return
+ fi
+ fi
+ rm -rf "$target"
+ else
+ rm -f "$target"
+ fi
+ [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
+ [ "$SEL" -lt 0 ] && SEL=0
+ load_entries
+ fi
+}
+
+do_rename() {
+ entry=$(get_entry "$SEL")
+ _name="${entry%@}"; _name="${_name%/}"
+ goto "$(term_rows)" 1
+ printf '%s%s Rename: %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "${RESET}"
+ _init="$_name"
+ if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$_name" ]; then
+ mv "${CWD}/${_name}" "${CWD}/${READ_LINE}"
+ load_entries
+ else
+ NEED_FULL_REDRAW=1; draw
+ fi
+}
+
+do_mkdir() {
+ goto "$(term_rows)" 1
+ printf '%s%s Directory name: %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_chmod_x() {
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
+ _target=$(joinpath "${entry%@}")
+ if chmod +x "$_target" 2>/dev/null; then
+ INFO_MSG="chmod +x: ${entry}"
+ else
+ INFO_MSG="chmod failed: ${entry}"
+ fi
+ NEED_FULL_REDRAW=1
+}
+
+do_chmod_nox() {
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
+ _target=$(joinpath "${entry%@}")
+ if chmod -x "$_target" 2>/dev/null; then
+ INFO_MSG="chmod -x: ${entry}"
+ else
+ INFO_MSG="chmod failed: ${entry}"
+ fi
+ NEED_FULL_REDRAW=1
+}
+
+do_info() {
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ _name="${entry%@}"; _name="${_name%/}"
+ target="${CWD}/${_name}"
+
+ # gather info
+ _info_perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}')
+ _info_owner=$(ls -ld "$target" 2>/dev/null | awk '{print $3}')
+ _info_group=$(ls -ld "$target" 2>/dev/null | awk '{print $4}')
+ _info_size=$(du -sh "$target" 2>/dev/null | cut -f1)
+ [ -z "$_info_size" ] && _info_size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}')
+ _info_date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}')
+
+ case "$entry" in
+ */) _info_type="directory" ;;
+ *@) _info_type="symlink -> $(ls -ld "$target" 2>/dev/null | awk '{print $NF}')" ;;
+ *) _info_type="file"
+ if [ -x "$target" ]; then _info_type="executable"; fi ;;
+ esac
+
+ _info_lines=""
+ _info_lines="${_info_lines}Name: ${_name}
+"
+ _info_lines="${_info_lines}Type: ${_info_type}
+"
+ _info_lines="${_info_lines}Perm: ${_info_perm}
+"
+ _info_lines="${_info_lines}Owner: ${_info_owner}:${_info_group}
+"
+ _info_lines="${_info_lines}Size: ${_info_size}
+"
+ _info_lines="${_info_lines}Mod: ${_info_date}
+"
+
+ # measure longest line
+ _maxw=0
+ _info_rest="$_info_lines"
+ while [ -n "$_info_rest" ]; do
+ _info_l="${_info_rest%%
+*}"
+ [ "${#_info_l}" -gt "$_maxw" ] && _maxw=${#_info_l}
+ _info_next="${_info_rest#*
+}"
+ [ "$_info_next" = "$_info_rest" ] && break
+ _info_rest="$_info_next"
+ done
+
+ _info_h=8 # 6 data rows + top + bottom
+ ROWS=$(term_rows); COLS=$(term_cols)
+ _iw=$((_maxw + 2)); [ "$_iw" -lt 20 ] && _iw=20
+ _bw=$((_iw + 2)); [ "$_bw" -gt "$COLS" ] && _bw=$COLS && _iw=$((_bw - 2))
+ _ix=$(( (COLS - _bw) / 2 )); [ "$_ix" -lt 0 ] && _ix=0
+ _iy=$(( (ROWS - _info_h) / 2 )); [ "$_iy" -lt 1 ] && _iy=1
+ _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-')
+
+ # draw
+ goto "$_iy" "$_ix"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+ goto "$((_iy+1))" "$_ix"
+ _title=" FILE INFO"
+ _tpad=$((_iw - ${#_title}))
+ [ "$_tpad" -lt 0 ] && _tpad=0
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_title" "$(printf '%*s' "$_tpad" '')" "${BOLD}${CYAN}" "${RESET}"
+ goto "$((_iy+2))" "$_ix"
+ printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+
+ _ri=3
+ _info_rest="$_info_lines"
+ while [ -n "$_info_rest" ]; do
+ _info_l="${_info_rest%%
+*}"
+ _info_next="${_info_rest#*
+}"
+ [ "$_info_next" = "$_info_rest" ] && break
+ _info_rest="$_info_next"
+ [ -z "$_info_l" ] && continue
+ goto "$((_iy+_ri))" "$_ix"
+ _lpad=$((_iw - ${#_info_l}))
+ [ "$_lpad" -lt 0 ] && _lpad=0
+ printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_info_l" "$(printf '%*s' "$_lpad" '')" "${BOLD}${CYAN}" "${RESET}"
+ _ri=$((_ri + 1))
+ done
+
+ goto "$((_iy+_ri))" "$_ix"
+ printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
+
+ # wait for key
+ IFS= read -r -n1 _infok 2>/dev/null || IFS= read -r _infok
+ case "$_infok" in
+ "$(printf '\033')")
+ IFS= read -r -n5 -t 0.1 _infoseq 2>/dev/null || _infoseq="" ;;
+ esac
+ 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}"; norm_cwd
+ 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
+ _name="${entry%@}"; _name="${_name%/}"
+ target="${CWD}/${_name}"
+ _ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s')
+ _dest="${TRASH_DIR}/${_ts}_${_name}"
+ 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"; norm_cwd
+ SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
+ load_entries
+}
+
+do_copy_path() {
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ _name="${entry%@}"; _name="${_name%/}"
+ _path="${CWD}/${_name}"
+ 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"; norm_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
+ _bvis=$((_ph - 3))
+ _boff=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}"
+ # clear visible rows first
+ _bri=0
+ while [ "$_bri" -lt "$_bvis" ]; do
+ goto "$((_py + 3 + _bri))" "$_px"
+ _bep=$(printf '%*s' "$_iw" '')
+ printf '%s|%s|%s' "${BOLD}${CYAN}" "$_bep" "${RESET}"
+ _bri=$((_bri + 1))
+ done
+ # draw visible bookmarks
+ _bi=0
+ while IFS= read -r _bm; do
+ [ -z "$_bm" ] && continue
+ _bi=$((_bi+1))
+ [ "$_bi" -lt "$_boff" ] && continue
+ [ "$_bi" -ge $((_boff + _bvis)) ] && continue
+ _drow=$((_py + 2 + _bi - _boff + 1))
+ goto "$_drow" "$_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
+ [ "$_bsel" -ge $((_boff + _bvis)) ] && _boff=$((_bsel - _bvis + 1)) ;;
+ k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1
+ [ "$_bsel" -lt "$_boff" ] && _boff=$_bsel ;;
+ "$(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
+ [ "$_bsel" -lt "$_boff" ] && _boff=$_bsel ;;
+ '[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc
+ [ "$_bsel" -ge $((_boff + _bvis)) ] && _boff=$((_bsel - _bvis + 1)) ;;
+ '[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"; norm_cwd
+ 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"; norm_cwd
+ 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
+ _yname="${_e%@}"; _yname="${_yname%/}"
+ printf '%s\n' "${CWD}/${_yname}"
+ done)
+ CLIP_MODE="copy"
+ INFO_MSG="yanked ${_c} items"
+ SELECTED=""
+ else
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ _yname="${entry%@}"; _yname="${_yname%/}"
+ CLIPBOARD="${CWD}/${_yname}"
+ 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
+ _cname="${_e%@}"; _cname="${_cname%/}"
+ printf '%s\n' "${CWD}/${_cname}"
+ done)
+ CLIP_MODE="cut"
+ INFO_MSG="cut ${_c} items"
+ SELECTED=""
+ else
+ entry=$(get_entry "$SEL")
+ [ -z "$entry" ] && return
+ _cname="${entry%@}"; _cname="${_cname%/}"
+ CLIPBOARD="${CWD}/${_cname}"
+ 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
+ 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" ;;
+ cut) mv "$_src" "$_dest" ;;
+ esac
+ done
+ CLIPBOARD=""; CLIP_MODE=""
+ INFO_MSG="pasted"
+ load_entries
+}
+
+# --- main ---
+trap 'restore_term; show_cursor; printf "\033[2J\033[H"; rm -f /tmp/_sfm_list_$$ /tmp/_sfm_find_$$; exit 0' INT QUIT 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 '\033[3~')") do_delete ;;
+ "$(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 ;;
+ P) do_toggle_preview ;;
+ i) do_info ;;
+ '+') do_chmod_x ;;
+ '-') do_chmod_nox ;;
+ o) do_open_with ;;
+ s) do_sort ;;
+ u) do_trash ;;
+ U) do_open_trash ;;
+ f) do_find ;;
+ ':') do_jump_path ;;
+ '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); 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 ;;
+ '!') do_shell ;;
+ esac
+done
+
+restore_term
+show_cursor
+printf '\033[2J\033[H'
+printf '%s\n' "$CWD"