Files
sfm/sfm
T

1407 lines
44 KiB
Bash
Executable File

#!/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"; CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/"
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
# 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
# build list of dir names then file names, including dotfiles if enabled
for d in "$CWD"/*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue # symlinks handled separately
name="${d%/}"; name="${name##*/}"
[ "$name" = "*" ] && continue
case "$name" in .*) continue ;; esac
ENTRIES="$ENTRIES
${name}/"
COUNT=$((COUNT + 1))
done
if [ "$SHOW_HIDDEN" = "1" ]; then
for d in "$CWD"/./; do : ; done
for d in "$CWD"/.*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue # symlinks handled separately
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=$(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
}
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}"; 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
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"; norm_cwd
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"; 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
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"; 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
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=$(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 ;;
esac
done
restore_term
show_cursor
printf '\033[2J\033[H'
printf '%s\n' "$CWD"