sfm

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

sfm (50046B)


      1 #!/bin/sh
      2 # sfm - Simple File Manager in POSIX sh (flicker-free)
      3 # version 0.4
      4 
      5 # --- terminal control ---
      6 tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; }
      7 
      8 RESET=$(tput_cmd sgr0)
      9 BOLD=$(tput_cmd bold)
     10 REV=$(tput_cmd rev)
     11 RED=$(tput_cmd setaf 1)
     12 GREEN=$(tput_cmd setaf 2)
     13 YELLOW=$(tput_cmd setaf 3)
     14 CYAN=$(tput_cmd setaf 6)
     15 WHITE=$(tput_cmd setaf 7)
     16 MAGENTA=$(tput_cmd setaf 5)
     17 BLUE=$(tput_cmd setaf 4)
     18 ERASE_LINE=$(tput_cmd el || printf '\033[K')
     19 
     20 # map a filename to a display colour based on extension
     21 file_colour() {
     22     _fc_name="$1"
     23     _fc_ext="${_fc_name##*.}"
     24     _fc_ext=$(printf '%s' "$_fc_ext" | tr '[:upper:]' '[:lower:]')
     25     # executable?
     26     [ -x "${CWD}/${_fc_name}" ] && { printf '%s' "${RED}${BOLD}"; return; }
     27     case "$_fc_ext" in
     28         # images
     29         jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif)
     30             printf '%s' "${MAGENTA}" ;;
     31         # video
     32         mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv)
     33             printf '%s' "${MAGENTA}${BOLD}" ;;
     34         # audio
     35         mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff)
     36             printf '%s' "${CYAN}${BOLD}" ;;
     37         # archives
     38         zip|tar|gz|bz2|xz|zst|7z|rar|tgz|tbz2)
     39             printf '%s' "${RED}" ;;
     40         # documents / text
     41         pdf|doc|docx|odt|xls|xlsx|ods|ppt|pptx|odp)
     42             printf '%s' "${YELLOW}" ;;
     43         # code / scripts
     44         sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|\
     45         c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|cs|php|r)
     46             printf '%s' "${GREEN}" ;;
     47         # data / config
     48         json|xml|yaml|yml|toml|ini|conf|cfg|env)
     49             printf '%s' "${CYAN}" ;;
     50         # markdown / text
     51         md|markdown|rst|txt|log)
     52             printf '%s' "${WHITE}" ;;
     53         # default
     54         *) printf '%s' "${WHITE}" ;;
     55     esac
     56 }
     57 
     58 goto()        { printf '\033[%d;%dH' "$1" "$2"; }
     59 hide_cursor() { printf '\033[?25l'; }
     60 show_cursor() { printf '\033[?25h'; }
     61 term_rows()   { tput_cmd lines || echo 24; }
     62 term_cols()   { tput_cmd cols  || echo 80; }
     63 
     64 # --- state ---
     65 if [ -n "$1" ]; then
     66     CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; }
     67 else
     68     CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/"
     69 fi
     70 SEL=0
     71 OFFSET=0
     72 PREV_SEL=0
     73 PREV_OFFSET=0
     74 NEED_FULL_REDRAW=1
     75 LAST_CHILD=""   # name of dir we came from, used to restore selection on go-back
     76 FILTER=""       # current search string; empty = no filter
     77 ALL_ENTRIES=""  # unfiltered entries
     78 ALL_COUNT=0
     79 SEARCHING=0     # 1 while in search input mode
     80 SELECTED=""     # newline-separated list of multi-selected entry names
     81 CLIPBOARD=""    # full path of yanked/cut entry
     82 CLIP_MODE=""    # "copy" or "cut"
     83 INFO_MSG=""     # ephemeral message shown in botbar
     84 SHOW_HIDDEN=0   # 1 = show dotfiles
     85 SORT_MODE="name" # name | size | date
     86 SHOW_DETAILS=0   # 1 = show size+date column
     87 SHOW_PREVIEW=0   # 1 = show preview pane on right
     88 TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash"
     89 mkdir -p "$TRASH_DIR"
     90 PREV_CWD=""     # last visited directory, for jumping back
     91 
     92 # normalise CWD: resolve to absolute canonical path, no double slashes
     93 norm_cwd() {
     94     CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/"
     95     # squeeze any double slashes (some systems return // for //<path>)
     96     CWD=$(printf '%s' "$CWD" | tr -s '/')
     97 }
     98 
     99 # safely join CWD with a name, avoiding double slash at root
    100 joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; }
    101 BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks"
    102 mkdir -p "$(dirname "$BOOKMARK_FILE")"
    103 [ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE"
    104 
    105 # --- load directory ---
    106 load_entries() {
    107     ENTRIES=""
    108     COUNT=0
    109 
    110     # build list of dir names then file names, including dotfiles if enabled
    111     for d in "$CWD"/*/; do
    112         [ -d "$d" ] || continue
    113         [ -L "${d%/}" ] && continue   # symlinks handled separately
    114         name="${d%/}"; name="${name##*/}"
    115         [ "$name" = "*" ] && continue
    116         case "$name" in .*) continue ;; esac
    117         ENTRIES="$ENTRIES
    118 ${name}/"
    119         COUNT=$((COUNT + 1))
    120     done
    121     if [ "$SHOW_HIDDEN" = "1" ]; then
    122         for d in "$CWD"/./; do : ; done
    123         for d in "$CWD"/.*/; do
    124             [ -d "$d" ] || continue
    125             [ -L "${d%/}" ] && continue   # symlinks handled separately
    126             name="${d%/}"; name="${name##*/}"
    127             [ "$name" = "." ] || [ "$name" = ".." ] && continue
    128             [ "$name" = ".*" ] && continue
    129             ENTRIES="$ENTRIES
    130 ${name}/"
    131             COUNT=$((COUNT + 1))
    132         done
    133     fi
    134 
    135     for f in "$CWD"/*; do
    136         [ -e "$f" ] || [ -L "$f" ] || continue
    137         [ -d "$f" ] && ! [ -L "$f" ] && continue
    138         name="${f##*/}"
    139         [ "$name" = "*" ] && continue
    140         case "$name" in .*) continue ;; esac
    141         if [ -L "$f" ]; then
    142             ENTRIES="$ENTRIES
    143 ${name}@"
    144         else
    145             ENTRIES="$ENTRIES
    146 ${name}"
    147         fi
    148         COUNT=$((COUNT + 1))
    149     done
    150     if [ "$SHOW_HIDDEN" = "1" ]; then
    151         for f in "$CWD"/.*; do
    152             [ -e "$f" ] || [ -L "$f" ] || continue
    153             [ -d "$f" ] && ! [ -L "$f" ] && continue
    154             name="${f##*/}"
    155             [ "$name" = ".*" ] && continue
    156             if [ -L "$f" ]; then
    157                 ENTRIES="$ENTRIES
    158 ${name}@"
    159             else
    160                 ENTRIES="$ENTRIES
    161 ${name}"
    162             fi
    163             COUNT=$((COUNT + 1))
    164         done
    165     fi
    166 
    167     ENTRIES="${ENTRIES#
    168 }"
    169 
    170     # --- sort files only (dirs always stay first) ---
    171     if [ "$SORT_MODE" != "name" ]; then
    172         # separate dirs and files
    173         _dirs=""; _files=""
    174         _rest="$ENTRIES"
    175         while [ -n "$_rest" ]; do
    176             _line="${_rest%%
    177 *}"; _next="${_rest#*
    178 }"
    179             [ "$_next" = "$_rest" ] && _next=""
    180             _rest="$_next"
    181             [ -z "$_line" ] && continue
    182             case "$_line" in
    183                 */)  if [ -z "$_dirs" ]; then _dirs="$_line"
    184                      else _dirs="$_dirs
    185 $_line"; fi ;;
    186                 *)   if [ -z "$_files" ]; then _files="$_line"
    187                      else _files="$_files
    188 $_line"; fi ;;
    189             esac
    190         done
    191 
    192         # sort files using ls into a tmp file
    193         if [ -n "$_files" ]; then
    194             case "$SORT_MODE" in
    195                 size) ls -1Sp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;;
    196                 date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;;
    197             esac
    198             # only keep files that were already in our list (respects hidden filter)
    199             _sorted=""
    200             while IFS= read -r _n; do
    201                 [ -z "$_n" ] && continue
    202                 # check if _n is in _files
    203                 case "
    204 ${_files}
    205 " in *"
    206 ${_n}
    207 "*) if [ -z "$_sorted" ]; then _sorted="$_n"
    208                     else _sorted="$_sorted
    209 $_n"; fi ;;
    210                 esac
    211             done < /tmp/_fm_sorted
    212             _files="$_sorted"
    213         fi
    214 
    215         # reassemble: dirs + sorted files
    216         ENTRIES=""
    217         _rest="$_dirs"
    218         while [ -n "$_rest" ]; do
    219             _line="${_rest%%
    220 *}"; _next="${_rest#*
    221 }"
    222             [ "$_next" = "$_rest" ] && _next=""
    223             _rest="$_next"
    224             [ -z "$_line" ] && continue
    225             if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
    226             else ENTRIES="$ENTRIES
    227 $_line"; fi
    228         done
    229         _rest="$_files"
    230         while [ -n "$_rest" ]; do
    231             _line="${_rest%%
    232 *}"; _next="${_rest#*
    233 }"
    234             [ "$_next" = "$_rest" ] && _next=""
    235             _rest="$_next"
    236             [ -z "$_line" ] && continue
    237             if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
    238             else ENTRIES="$ENTRIES
    239 $_line"; fi
    240         done
    241         # recount
    242         COUNT=0
    243         _rest="$ENTRIES"
    244         while [ -n "$_rest" ]; do
    245             _line="${_rest%%
    246 *}"; _next="${_rest#*
    247 }"
    248             [ "$_next" = "$_rest" ] && _next=""
    249             _rest="$_next"
    250             [ -n "$_line" ] && COUNT=$((COUNT + 1))
    251         done
    252     fi
    253 
    254     ALL_ENTRIES="$ENTRIES"
    255     ALL_COUNT="$COUNT"
    256     apply_filter
    257     NEED_FULL_REDRAW=1
    258 }
    259 
    260 # filter ALL_ENTRIES by FILTER string, update ENTRIES/COUNT
    261 apply_filter() {
    262     if [ -z "$FILTER" ]; then
    263         ENTRIES="$ALL_ENTRIES"
    264         COUNT="$ALL_COUNT"
    265         return
    266     fi
    267     ENTRIES=""
    268     COUNT=0
    269     printf '%s\n' "$ALL_ENTRIES" | while IFS= read -r line; do
    270         case "$line" in
    271             *"$FILTER"*) printf '%s\n' "$line" ;;
    272         esac
    273     done | {
    274         while IFS= read -r line; do
    275             ENTRIES="$ENTRIES
    276 $line"
    277             COUNT=$((COUNT + 1))
    278         done
    279         # write back via temp — subshell can't modify parent vars directly
    280         printf '%s\n' "$COUNT" > /tmp/_fm_count
    281         printf '%s' "$ENTRIES" > /tmp/_fm_entries
    282     }
    283     COUNT=$(cat /tmp/_fm_count 2>/dev/null || echo 0)
    284     ENTRIES=$(cat /tmp/_fm_entries 2>/dev/null || echo "")
    285     ENTRIES="${ENTRIES#
    286 }"
    287 }
    288 
    289 get_entry() {
    290     printf '%s\n' "$ENTRIES" | awk -v n="$(($1 + 1))" 'NR==n{print; exit}'
    291 }
    292 
    293 # find index of entry by name (0-based), returns -1 if not found
    294 find_entry() {
    295     printf '%s\n' "$ENTRIES" | awk -v name="$1" '
    296         { if ($0 == name) { print NR-1; found=1; exit } }
    297         END { if (!found) print -1 }
    298     '
    299 }
    300 
    301 # is entry name in SELECTED list?
    302 is_selected() {
    303     case "
    304 ${SELECTED}
    305 " in *"
    306 $1
    307 "*) return 0 ;; esac
    308     return 1
    309 }
    310 
    311 count_selected() {
    312     _cnt=0
    313     _rest="$SELECTED"
    314     while [ -n "$_rest" ]; do
    315         _line="${_rest%%
    316 *}"
    317         _rest="${_rest#*
    318 }"
    319         [ "$_rest" = "$_line" ] && _rest=""   # no more newlines
    320         [ -n "$_line" ] && _cnt=$((_cnt + 1))
    321     done
    322     printf '%d' "$_cnt"
    323 }
    324 
    325 
    326 toggle_selected() {
    327     if is_selected "$1"; then
    328         _new=""
    329         _rest="$SELECTED"
    330         while [ -n "$_rest" ]; do
    331             _line="${_rest%%
    332 *}"
    333             _next="${_rest#*
    334 }"
    335             [ "$_next" = "$_rest" ] && _next=""
    336             _rest="$_next"
    337             [ "$_line" = "$1" ] && continue
    338             [ -z "$_line" ]     && continue
    339             if [ -z "$_new" ]; then _new="$_line"
    340             else _new="$_new
    341 $_line"
    342             fi
    343         done
    344         SELECTED="$_new"
    345     else
    346         if [ -z "$SELECTED" ]; then
    347             SELECTED="$1"
    348         else
    349             SELECTED="$SELECTED
    350 $1"
    351         fi
    352     fi
    353 }
    354 
    355 # render one entry row in-place (no newline, no cursor movement after)
    356 # args: row  idx  is_selected  cols
    357 render_row() {
    358     _row=$1; _idx=$2; _selected=$3; _cols=$4
    359     entry=$(get_entry "$_idx")
    360 
    361     case "$entry" in
    362         */) colour="${GREEN}${BOLD}" ;;
    363         *@)
    364             _name="${entry%@}"
    365             # broken symlink if target doesn't exist
    366             if [ -e "${CWD}/${_name}" ]; then
    367                 colour="${MAGENTA}${BOLD}"
    368             else
    369                 colour="${RED}${BOLD}"
    370             fi ;;
    371         *)  colour=$(file_colour "$entry") ;;
    372     esac
    373 
    374     # multi-select marker
    375     if is_selected "$entry"; then
    376         _marker="${YELLOW}*${colour}"
    377     else
    378         _marker=" "
    379     fi
    380 
    381     # build display string
    382     case "$entry" in
    383         *@)
    384             _name="${entry%@}"
    385             _target=$(readlink "${CWD}/${_name}" 2>/dev/null || echo "?")
    386             # mark broken symlinks clearly
    387             if [ ! -e "${CWD}/${_name}" ]; then
    388                 display="${_name} -> ${_target} [broken]"
    389             else
    390                 display="${_name} -> ${_target}"
    391             fi
    392             ;;
    393         *)
    394             display="${entry}"
    395             ;;
    396     esac
    397 
    398     # build detail string (size + date) if enabled — fixed width for alignment
    399     _detail=""
    400     if [ "$SHOW_DETAILS" = "1" ]; then
    401         _path="${CWD}/${entry%/}"; _path="${_path%@}"
    402         _info=$(ls -ldh "$_path" 2>/dev/null)
    403         _sz=$(printf '%s' "$_info" | awk '{print $5}')
    404         _dt=$(printf '%s' "$_info" | awk '{print $6, $7}')
    405         # fixed width: size=6, date=6 → total detail = 14 chars
    406         _detail=$(printf ' %6s  %-6s' "$_sz" "$_dt")
    407     fi
    408 
    409     # layout: marker(1) + name + spaces + detail
    410     _dlen=${#_detail}
    411     maxw=$((_cols - 1 - _dlen - 1))
    412     if [ "${#display}" -gt "$maxw" ]; then
    413         display=$(printf '%s' "$display" | cut -c1-$((_cols - _dlen - 4)))
    414         display="${display}..."
    415     fi
    416     _namew=$((${#display} + 1))   # 1 for marker
    417     padlen=$((_cols - _namew - _dlen))
    418     [ "$padlen" -lt 0 ] && padlen=0
    419     spaces=$(printf '%*s' "$padlen" '')
    420 
    421     goto "$_row" 1
    422     if [ "$_selected" = "1" ]; then
    423         printf '%s%s%s%s%s%s%s' \
    424             "${REV}${colour}" "${_marker}" "${display}" \
    425             "${spaces}" \
    426             "${colour}${_detail}" \
    427             "${RESET}" ""
    428     else
    429         printf '%s%s%s%s%s%s%s' \
    430             "${colour}" "${_marker}" "${display}" \
    431             "${spaces}" \
    432             "${colour}${_detail}" \
    433             "${RESET}" ""
    434     fi
    435 }
    436 
    437 draw_preview() {
    438     _rows=$1; _cols=$2; _px=$3; _pw=$4
    439     entry=$(get_entry "$SEL")
    440     # clear preview area first
    441     _r=2
    442     while [ "$_r" -le $((_rows - 1)) ]; do
    443         goto "$_r" "$_px"
    444         printf '\033[K'
    445         _r=$((_r + 1))
    446     done
    447     [ -z "$entry" ] && return
    448     _path=$(joinpath "${entry%/}"); _path="${_path%@}"
    449 
    450     # draw vertical divider
    451     _r=2
    452     while [ "$_r" -le $((_rows - 1)) ]; do
    453         goto "$_r" $((_px - 1))
    454         printf '%s|%s' "${CYAN}" "${RESET}"
    455         _r=$((_r + 1))
    456     done
    457 
    458     case "$entry" in
    459         */)
    460             # directory: list contents
    461             _max_lines=$((_rows - 3))
    462             _pr=2
    463             for _de in "$_path"/*/; do
    464                 [ "$_pr" -gt $((_rows - 1)) ] && break
    465                 [ -d "$_de" ] || continue
    466                 _dn="${_de%/}"; _dn="${_dn##*/}"
    467                 [ "$_dn" = "*" ] && continue
    468                 _dl=" ${_dn}/"
    469                 [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~"
    470                 goto "$_pr" "$_px"
    471                 printf '%s%s%s' "${GREEN}${BOLD}" "$_dl" "${RESET}"
    472                 _pr=$((_pr + 1))
    473             done
    474             for _fe in "$_path"/*; do
    475                 [ "$_pr" -gt $((_rows - 1)) ] && break
    476                 [ -e "$_fe" ] || continue
    477                 [ -d "$_fe" ] && continue
    478                 _fn="${_fe##*/}"
    479                 [ "$_fn" = "*" ] && continue
    480                 _fl=" ${_fn}"
    481                 [ "${#_fl}" -gt "$((_pw - 1))" ] && _fl="$(printf '%s' "$_fl" | cut -c1-$((_pw-2)))~"
    482                 goto "$_pr" "$_px"
    483                 printf '%s%s%s' "${WHITE}" "$_fl" "${RESET}"
    484                 _pr=$((_pr + 1))
    485             done
    486             if [ "$_pr" -eq 2 ]; then
    487                 goto 2 "$_px"
    488                 printf '%s(empty)%s' "${WHITE}" "${RESET}"
    489             fi
    490             ;;
    491         *@)
    492             # symlink
    493             _tgt=$(readlink "$_path" 2>/dev/null || echo "?")
    494             goto 2 "$_px"
    495             printf '%s[symlink]%s' "${MAGENTA}${BOLD}" "${RESET}"
    496             goto 3 "$_px"
    497             printf '%s-> %s%s' "${WHITE}" "${_tgt}" "${RESET}"
    498             ;;
    499         *)
    500             # detect if text via extension or mime
    501             _ext="${entry##*.}"
    502             _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')
    503             _is_text=0
    504             case "$_ext" in
    505                 txt|md|markdown|rst|log|conf|cfg|ini|toml|yaml|yml|\
    506                 sh|bash|zsh|py|rb|pl|lua|js|ts|json|xml|html|htm|\
    507                 css|c|h|cpp|rs|go|java|php|r|sql|vim|env|gitignore|\
    508                 diff|patch|csv|tsv|lock|mod|sum) _is_text=1 ;;
    509             esac
    510             if [ "$_is_text" = "0" ] && command -v file >/dev/null 2>&1; then
    511                 case "$(file --mime-type -b "$_path" 2>/dev/null)" in
    512                     text/*) _is_text=1 ;;
    513                 esac
    514             fi
    515             if [ "$_is_text" = "1" ]; then
    516                 _max_lines=$((_rows - 3))
    517                 _line_n=0
    518                 while IFS= read -r _line && [ "$_line_n" -lt "$_max_lines" ]; do
    519                     # truncate to preview width
    520                     if [ "${#_line}" -gt "$((_pw - 1))" ]; then
    521                         _line=$(printf '%s' "$_line" | cut -c1-$((_pw - 2)))
    522                         _line="${_line}~"
    523                     fi
    524                     goto "$((_line_n + 2))" "$_px"
    525                     printf '%s%s%s' "${WHITE}" "$_line" "${RESET}"
    526                     _line_n=$((_line_n + 1))
    527                 done < "$_path"
    528                 if [ "$_line_n" -eq 0 ]; then
    529                     goto 2 "$_px"
    530                     printf '%s(empty file)%s' "${WHITE}" "${RESET}"
    531                 fi
    532             else
    533                 # binary / unknown
    534                 goto 2 "$_px"
    535                 _sz=$(ls -lh "$_path" 2>/dev/null | awk '{print $5}')
    536                 printf '%s[binary] %s%s' "${YELLOW}" "${_sz}" "${RESET}"
    537             fi
    538             ;;
    539     esac
    540 }
    541 
    542 draw_topbar() {
    543     _cols=$1
    544     spaces=$(printf '%*s' "$_cols" '')
    545     goto 1 1
    546     printf '%s%s%s' "${CYAN}${BOLD}" "${spaces}" "${RESET}"
    547 }
    548 
    549 draw_botbar() {
    550     _rows=$1; _cols=$2
    551     if [ "$SEARCHING" = "1" ]; then
    552         info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}"
    553         keys=" search: ${FILTER} "
    554         maxk=$((_cols - ${#info} - 1))
    555         [ "${#keys}" -gt "$maxk" ] && keys=$(printf '%s' "$keys" | cut -c1-"$maxk")
    556         padlen=$((_cols - ${#info} - ${#keys}))
    557         [ "$padlen" -lt 0 ] && padlen=0
    558         pad=$(printf '%*s' "$padlen" '')
    559         goto "$_rows" 1
    560         printf '%s%s%s%s%s%s' \
    561             "${BOLD}${CYAN}" "${info}" \
    562             "${RESET}${pad}" \
    563             "${YELLOW}${keys}" \
    564             "${RESET}"
    565     elif [ -n "$INFO_MSG" ]; then
    566         info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}"
    567         msg=" ${INFO_MSG} "        padlen=$((_cols - ${#info} - ${#msg}))
    568         [ "$padlen" -lt 0 ] && padlen=0
    569         pad=$(printf '%*s' "$padlen" '')
    570         goto "$_rows" 1
    571         printf '%s%s%s%s%s%s' \
    572             "${BOLD}${CYAN}" "${info}" \
    573             "${RESET}${pad}" \
    574             "${YELLOW}${msg}" \
    575             "${RESET}"
    576         INFO_MSG=""
    577     else
    578         _ind=""
    579         [ -n "$CLIPBOARD" ]        && _ind=" [${CLIP_MODE}]"
    580         _sc=$(count_selected)
    581         [ "$_sc" -gt 0 ]           && _ind="${_ind} [sel:${_sc}]"
    582         [ "$SHOW_HIDDEN" = "1" ]   && _ind="${_ind} [hidden]"
    583         [ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]"
    584         [ "$SHOW_DETAILS" = "1" ]  && _ind="${_ind} [details]"
    585         hint="  press ? for help "
    586         info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}${_ind}"
    587         padlen=$((_cols - ${#info} - ${#hint}))
    588         [ "$padlen" -lt 0 ] && padlen=0
    589         pad=$(printf '%*s' "$padlen" '')
    590         goto "$_rows" 1
    591         printf '%s%s%s%s%s%s' \
    592             "${BOLD}${CYAN}" "${info}" \
    593             "${RESET}${pad}" \
    594             "${YELLOW}${hint}" \
    595             "${RESET}"
    596     fi
    597 }
    598 
    599 draw() {
    600     ROWS=$(term_rows)
    601     COLS=$(term_cols)
    602     LIST_ROWS=$((ROWS - 2))   # row 1 = topbar, row ROWS = botbar
    603 
    604     # split layout when preview enabled
    605     if [ "$SHOW_PREVIEW" = "1" ]; then
    606         LIST_COLS=$((COLS / 2))
    607         PREV_COL=$((LIST_COLS + 2))
    608         PREV_WIDTH=$((COLS - LIST_COLS - 2))
    609     else
    610         LIST_COLS=$COLS
    611     fi
    612 
    613     # clamp selection
    614     [ "$SEL" -lt 0 ] && SEL=0
    615     [ "$COUNT" -gt 0 ] && [ "$SEL" -ge "$COUNT" ] && SEL=$((COUNT - 1))
    616 
    617     # update scroll offset
    618     if [ "$SEL" -lt "$OFFSET" ]; then
    619         OFFSET=$SEL
    620     elif [ "$SEL" -ge $((OFFSET + LIST_ROWS)) ]; then
    621         OFFSET=$((SEL - LIST_ROWS + 1))
    622     fi
    623 
    624     # viewport shifted → must redraw all rows
    625     [ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1
    626 
    627     if [ "$NEED_FULL_REDRAW" = "1" ]; then
    628         printf '\033[H'
    629         draw_topbar "$COLS"
    630 
    631         row=2
    632         idx=$OFFSET
    633         while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do
    634             sel=0; [ "$idx" -eq "$SEL" ] && sel=1
    635             render_row "$row" "$idx" "$sel" "$LIST_COLS"
    636             row=$((row + 1))
    637             idx=$((idx + 1))
    638         done
    639         # show (empty) if directory has no entries
    640         if [ "$COUNT" -eq 0 ]; then
    641             goto 2 1
    642             printf '%s  (empty)%s%s' "${WHITE}" "${ERASE_LINE}" "${RESET}"
    643             row=3
    644         fi
    645         # blank leftover rows
    646         while [ "$row" -le $((LIST_ROWS + 1)) ]; do
    647             goto "$row" 1
    648             printf '%s' "${ERASE_LINE}"
    649             row=$((row + 1))
    650         done
    651 
    652         [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"
    653 
    654         draw_botbar "$ROWS" "$COLS"
    655         NEED_FULL_REDRAW=0
    656     else
    657         # --- fast path ---
    658         if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then
    659             return 2>/dev/null || true
    660         fi
    661         prev_row=$(( (PREV_SEL - OFFSET) + 2 ))
    662         new_row=$(( (SEL      - OFFSET) + 2 ))
    663 
    664         if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then
    665             render_row "$prev_row" "$PREV_SEL" 0 "$LIST_COLS"
    666         fi
    667         if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then
    668             render_row "$new_row" "$SEL" 1 "$LIST_COLS"
    669         fi
    670 
    671         [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"
    672 
    673         draw_botbar "$ROWS" "$COLS"
    674     fi
    675 
    676     PREV_SEL=$SEL
    677     PREV_OFFSET=$OFFSET
    678 }
    679 
    680 # --- input ---
    681 read_key() {
    682     IFS= read -r -n1 key 2>/dev/null || IFS= read -r key
    683     if [ "$key" = "$(printf '\033')" ]; then
    684         # peek at next char with a very short timeout
    685         IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2=""
    686         if [ -z "$_k2" ]; then
    687             # nothing followed — bare esc
    688             printf '\033'; return
    689         fi
    690         # got a second char — read one more for full sequence (e.g. [A [B [C [D)
    691         IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3=""
    692         key="${key}${_k2}${_k3}"
    693     fi
    694     printf '%s' "$key"
    695 }
    696 
    697 setup_term()   { old_stty=$(stty -g); stty -echo -icanon min 1 time 0; }
    698 restore_term() { stty "$old_stty"; }
    699 
    700 # --- actions ---
    701 # --- smart file opener ---
    702 # Detects file type by extension then mime, picks the right program.
    703 # Terminal programs run in the foreground; GUI programs are backgrounded.
    704 _run_tty() { "$@"; }
    705 _run_gui() { "$@" >/dev/null 2>&1 & }
    706 
    707 open_file() {
    708     _f="$1"
    709 
    710     # use user opener script if it exists and is executable
    711     _opener="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/opener"
    712     if [ -x "$_opener" ]; then
    713         "$_opener" "$_f"
    714         return
    715     fi
    716 
    717     _ext="${_f##*.}"
    718     _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')
    719 
    720     # --- by extension ---
    721     case "$_ext" in
    722         # text / code → editor
    723         txt|md|markdown|rst|csv|tsv|log|conf|cfg|ini|toml|yaml|yml|\
    724         sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|json|xml|html|\
    725         htm|css|scss|sass|c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|\
    726         cs|php|r|sql|vim|diff|patch|makefile|dockerfile|gitignore|\
    727         env|lock|mod|sum)
    728             _run_tty ${EDITOR:-vi} "$_f"
    729             return ;;
    730         # images → GUI viewer
    731         jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif)
    732             for _v in imv imvr feh sxiv nsxiv eog eom viewnior shotwell gimp; do
    733                 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    734             done ;;
    735         # video → GUI player
    736         mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv)
    737             for _v in mpv vlc mplayer totem celluloid haruna; do
    738                 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    739             done ;;
    740         # audio → player (mpv/vlc run fine headless too)
    741         mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff)
    742             for _v in mpv vlc mplayer cmus mocp; do
    743                 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    744             done ;;
    745         # PDF / documents
    746         pdf)
    747             for _v in zathura evince okular mupdf atril xreader; do
    748                 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    749             done ;;
    750         # office docs
    751         odt|ods|odp|doc|docx|xls|xlsx|ppt|pptx)
    752             for _v in libreoffice soffice; do
    753                 command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    754             done ;;
    755         # archives → list contents in pager
    756         zip|tar|gz|bz2|xz|zst|7z|rar)
    757             if command -v atool >/dev/null 2>&1; then
    758                 atool -l "$_f" 2>&1 | ${PAGER:-less}; return
    759             elif command -v bsdtar >/dev/null 2>&1; then
    760                 bsdtar -tf "$_f" 2>&1 | ${PAGER:-less}; return
    761             fi ;;
    762     esac
    763 
    764     # --- fallback: try mime type via `file` ---
    765     if command -v file >/dev/null 2>&1; then
    766         _mime=$(file --mime-type -b "$_f" 2>/dev/null)
    767         case "$_mime" in
    768             text/*|application/json|application/xml|application/javascript)
    769                 _run_tty ${EDITOR:-vi} "$_f"; return ;;
    770             image/*)
    771                 for _v in imv feh sxiv nsxiv eog gimp; do
    772                     command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    773                 done ;;
    774             video/*)
    775                 for _v in mpv vlc mplayer; do
    776                     command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    777                 done ;;
    778             audio/*)
    779                 for _v in mpv vlc mplayer; do
    780                     command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    781                 done ;;
    782             application/pdf)
    783                 for _v in zathura evince okular mupdf; do
    784                     command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
    785                 done ;;
    786         esac
    787     fi
    788 
    789     # --- last resort: xdg-open / open, else editor ---
    790     if command -v xdg-open >/dev/null 2>&1; then
    791         _run_gui xdg-open "$_f"
    792     elif command -v open >/dev/null 2>&1; then
    793         _run_gui open "$_f"
    794     else
    795         _run_tty ${EDITOR:-vi} "$_f"
    796     fi
    797 }
    798 
    799 # read a line of input in raw mode, returns result in READ_LINE
    800 # returns 1 if user pressed Esc (cancel), 0 on Enter
    801 read_line() {
    802     READ_LINE=""
    803     show_cursor
    804     while true; do
    805         IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch
    806         case "$_ch" in
    807             "$(printf '\033')")
    808                 # read with timeout to distinguish bare esc from sequences
    809                 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq=""
    810                 hide_cursor
    811                 READ_LINE=""; return 1 ;;
    812             "$(printf '\n')"|\
    813             "$(printf '\r')")
    814                 hide_cursor
    815                 return 0 ;;
    816             "$(printf '\177')"|\
    817             "$(printf '\010')")
    818                 if [ -n "$READ_LINE" ]; then
    819                     READ_LINE="${READ_LINE%?}"
    820                     printf '\b \b'
    821                 fi ;;
    822             *)
    823                 READ_LINE="${READ_LINE}${_ch}"
    824                 printf '%s' "$_ch" ;;
    825         esac
    826     done
    827 }
    828 
    829 do_open_with() {
    830     entry=$(get_entry "$SEL")
    831     [ -z "$entry" ] && return
    832     case "$entry" in
    833         */) INFO_MSG="cannot open-with a directory"; NEED_FULL_REDRAW=1; return ;;
    834     esac
    835     _target="${CWD}/${entry%@}"
    836     goto "$(term_rows)" 1
    837     printf '%s%s Open "%s" with (esc=cancel): %s' \
    838         "${ERASE_LINE}" "${CYAN}${BOLD}" "$entry" "${RESET}"
    839     if read_line && [ -n "$READ_LINE" ]; then
    840         # check the program exists
    841         if ! command -v "$READ_LINE" >/dev/null 2>&1; then
    842             INFO_MSG="not found: ${READ_LINE}"
    843             NEED_FULL_REDRAW=1
    844             return
    845         fi
    846         restore_term; show_cursor
    847         printf '\033[2J\033[H'
    848         "$READ_LINE" "$_target"
    849         setup_term; hide_cursor
    850         NEED_FULL_REDRAW=1
    851     else
    852         NEED_FULL_REDRAW=1; draw
    853     fi
    854 }
    855 
    856 do_newfile() {
    857     goto "$(term_rows)" 1
    858     printf '%s%s New file name (esc=cancel): %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}"
    859     if read_line && [ -n "$READ_LINE" ]; then
    860         touch "${CWD}/${READ_LINE}"
    861         load_entries
    862     else
    863         NEED_FULL_REDRAW=1; draw
    864     fi
    865 }
    866 
    867 # --- help overlay helpers (must be top-level for dash compatibility) ---
    868 _help_bx=0; _help_by=0; _help_iw=0; _help_hl=""
    869 
    870 help_row() {
    871     _r=$1; _text=$2
    872     goto "$(( _help_by + _r ))" "$_help_bx"
    873     if [ "${#_text}" -gt "$_help_iw" ]; then
    874         _text=$(printf '%s' "$_text" | cut -c1-"$_help_iw")
    875     fi
    876     _pl=$(( _help_iw - ${#_text} ))
    877     _p=$(printf '%*s' "$_pl" '')
    878     printf '%s|%s%s%s%s|%s' \
    879         "${BOLD}${CYAN}" "${RESET}" "${_text}" "${_p}" \
    880         "${BOLD}${CYAN}" "${RESET}"
    881 }
    882 
    883 help_sep() {
    884     goto "$(( _help_by + $1 ))" "$_help_bx"
    885     printf '%s|%s|%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"
    886 }
    887 
    888 do_help() {
    889     ROWS=$(term_rows)
    890     COLS=$(term_cols)
    891     bw=$((COLS - 4))
    892     [ "$bw" -gt 52 ] && bw=52
    893     [ "$bw" -lt 36 ] && bw=36
    894     bh=42
    895     _help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1
    896     _help_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1
    897     _help_iw=$((bw - 2))
    898     _help_hl=$(printf '%*s' "$_help_iw" '' | tr ' ' '-')
    899 
    900     goto "$_help_by" "$_help_bx"
    901     printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"
    902 
    903     help_row  1 ""
    904     help_row  2 "  KEYBOARD SHORTCUTS"
    905     help_sep  3
    906     help_row  4 "  j/k  up/down     g/G  top/bottom"
    907     help_row  5 "  h/left           go back"
    908     help_row  6 "  l/right/enter    open / enter dir"
    909     help_sep  7
    910     help_row  8 "  /     search filter"
    911     help_row  9 "  esc   clear filter"
    912     help_row 10 "  .     toggle hidden files"
    913     help_row 11 "  i     file info in status bar"
    914     help_row 12 "  s     cycle sort: name/size/date"
    915     help_row 13 "  T     toggle size/date details"
    916     help_row 14 "  P     toggle preview pane"
    917     help_sep 15
    918     help_row 16 "  space  toggle multi-select"
    919     help_row 17 "  a      select all / deselect all"
    920     help_row 18 "  y      yank/copy  (works on selection)"
    921     help_row 19 "  x      cut        (works on selection)"
    922     help_row 20 "  p      paste"
    923     help_row 21 "  d      delete     (works on selection)"
    924     help_sep 22
    925     help_row 23 "  r   rename         R   refresh"
    926     help_row 24 "  m   make directory"
    927     help_row 25 "  n   new file"
    928     help_row 26 "  u   trash file (safe delete)"
    929     help_row 27 "  U   open trash directory"
    930     help_row 28 "  !   drop to shell in CWD"
    931     help_row 29 "  o   open with custom program"
    932     help_sep 30
    933     help_row 31 "  b   bookmark current dir"
    934     help_row 32 "  B   open bookmark picker"
    935     help_row 33 "  c   copy path to clipboard"
    936     help_row 34 "  ~   go to home directory"
    937     help_row 35 "  \`   jump to previous directory"
    938     help_sep 36
    939     help_row 37 "  q   quit"
    940     help_row 38 "  ?   this help"
    941     help_row 39 ""
    942     help_row 40 "  press any key to close..."
    943 
    944     goto "$(( _help_by + bh ))" "$_help_bx"
    945     printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"
    946 
    947     read_key > /dev/null
    948     NEED_FULL_REDRAW=1
    949 }
    950 
    951 do_search() {
    952     FILTER=""
    953     SEARCHING=1
    954     show_cursor
    955     while true; do
    956         apply_filter
    957         SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    958         NEED_FULL_REDRAW=1
    959         draw
    960         IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch
    961         case "$_ch" in
    962             "$(printf '\033')")
    963                 # read with short timeout to catch escape sequences
    964                 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq=""
    965                 if [ -z "$_seq" ]; then
    966                     # bare esc — clear filter and exit search
    967                     FILTER=""; SEARCHING=0
    968                     apply_filter
    969                     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    970                     hide_cursor; NEED_FULL_REDRAW=1; draw; return
    971                 fi
    972                 # else it was an arrow key or other sequence — ignore it
    973                 ;;
    974             "$(printf '\n')"|\
    975             "$(printf '\r')")
    976                 SEARCHING=0; hide_cursor; NEED_FULL_REDRAW=1; return ;;
    977             "$(printf '\177')"|\
    978             "$(printf '\010')")
    979                 [ -n "$FILTER" ] && FILTER="${FILTER%?}" ;;
    980             *)
    981                 FILTER="${FILTER}${_ch}" ;;
    982         esac
    983     done
    984 }
    985 do_clear_filter() {
    986     FILTER=""
    987     SEARCHING=0
    988     apply_filter
    989     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    990     NEED_FULL_REDRAW=1
    991 }
    992 
    993 do_toggle_hidden() {
    994     if [ "$SHOW_HIDDEN" = "0" ]; then
    995         SHOW_HIDDEN=1
    996         INFO_MSG="hidden files shown"
    997     else
    998         SHOW_HIDDEN=0
    999         INFO_MSG="hidden files hidden"
   1000     fi
   1001     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1002     load_entries
   1003 }
   1004 
   1005 do_toggle_details() {
   1006     if [ "$SHOW_DETAILS" = "0" ]; then
   1007         SHOW_DETAILS=1; INFO_MSG="details on"
   1008     else
   1009         SHOW_DETAILS=0; INFO_MSG="details off"
   1010     fi
   1011     NEED_FULL_REDRAW=1
   1012 }
   1013 
   1014 do_toggle_preview() {
   1015     if [ "$SHOW_PREVIEW" = "0" ]; then
   1016         SHOW_PREVIEW=1; INFO_MSG="preview on"
   1017     else
   1018         SHOW_PREVIEW=0; INFO_MSG="preview off"
   1019     fi
   1020     NEED_FULL_REDRAW=1
   1021 }
   1022 
   1023 do_go_back() {
   1024     [ "$CWD" = "/" ] && return
   1025     FILTER=""
   1026     SELECTED=""
   1027     PREV_CWD="$CWD"
   1028     LAST_CHILD="${CWD##*/}/"   # name of current dir with trailing slash
   1029     CWD=$(cd "$(dirname "$CWD")" && pwd)
   1030     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1031     load_entries
   1032     # restore selection to the directory we just came from
   1033     if [ -n "$LAST_CHILD" ]; then
   1034         idx=$(find_entry "$LAST_CHILD")
   1035         [ "$idx" -ge 0 ] 2>/dev/null && SEL=$idx
   1036     fi
   1037     LAST_CHILD=""
   1038 }
   1039 
   1040 do_open() {
   1041     entry=$(get_entry "$SEL")
   1042     case "$entry" in
   1043         */)
   1044             FILTER=""
   1045             SELECTED=""
   1046             _target=$(joinpath "${entry%/}")
   1047             if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then
   1048                 INFO_MSG="permission denied: ${entry%/}"
   1049                 NEED_FULL_REDRAW=1
   1050                 return
   1051             fi
   1052             PREV_CWD="$CWD"
   1053             CWD=$(cd "$_target" && pwd); norm_cwd
   1054             SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1055             load_entries
   1056             ;;
   1057         *@)
   1058             _name="${entry%@}"
   1059             _target=$(joinpath "$_name")
   1060             if [ -d "$_target" ]; then
   1061                 if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then
   1062                     INFO_MSG="permission denied: ${_name}"
   1063                     NEED_FULL_REDRAW=1
   1064                     return
   1065                 fi
   1066                 FILTER=""; SELECTED=""
   1067                 PREV_CWD="$CWD"
   1068                 CWD=$(cd "$_target" && pwd); norm_cwd
   1069                 SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1070                 load_entries
   1071             else
   1072                 if [ ! -r "$_target" ]; then
   1073                     INFO_MSG="permission denied: ${_name}"
   1074                     NEED_FULL_REDRAW=1
   1075                     return
   1076                 fi
   1077                 restore_term; show_cursor
   1078                 printf '\033[2J\033[H'
   1079                 open_file "$_target"
   1080                 setup_term; hide_cursor
   1081                 NEED_FULL_REDRAW=1
   1082             fi
   1083             ;;
   1084         *)
   1085             _target="${CWD}/${entry}"
   1086             if [ ! -r "$_target" ]; then
   1087                 INFO_MSG="permission denied: ${entry}"
   1088                 NEED_FULL_REDRAW=1
   1089                 return
   1090             fi
   1091             restore_term; show_cursor
   1092             printf '\033[2J\033[H'
   1093             open_file "$_target"
   1094             setup_term; hide_cursor
   1095             NEED_FULL_REDRAW=1
   1096             ;;
   1097     esac
   1098 }
   1099 
   1100 do_delete() {
   1101     INFO_MSG=""
   1102     _c=$(count_selected)
   1103     if [ "$_c" -gt 0 ]; then
   1104         # --- bulk delete selected ---
   1105         restore_term; show_cursor
   1106         goto "$(term_rows)" 1
   1107         printf '%s%s Delete %d selected items? [y/N] %s' \
   1108             "${ERASE_LINE}" "${RED}${BOLD}" "$_c" "${RESET}"
   1109         IFS= read -r ans
   1110         setup_term; hide_cursor
   1111         case "$ans" in
   1112             y|Y)
   1113                 _rest="$SELECTED"
   1114                 while [ -n "$_rest" ]; do
   1115                     _line="${_rest%%
   1116 *}"
   1117                     _next="${_rest#*
   1118 }"
   1119                     [ "$_next" = "$_rest" ] && _next=""
   1120                     _rest="$_next"
   1121                     [ -z "$_line" ] && continue
   1122                     _t="${CWD}/${_line%/}"
   1123                     if [ -d "$_t" ]; then rm -rf "$_t"; else rm -f "$_t"; fi
   1124                 done
   1125                 SELECTED=""
   1126                 [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
   1127                 [ "$SEL" -lt 0 ] && SEL=0
   1128                 load_entries
   1129                 ;;
   1130             *) NEED_FULL_REDRAW=1 ;;
   1131         esac
   1132     else
   1133         # --- single delete ---
   1134         entry=$(get_entry "$SEL")
   1135         target="${CWD}/${entry%/}"
   1136         restore_term; show_cursor
   1137         goto "$(term_rows)" 1
   1138         printf '%s%s Delete "%s"? [y/N] %s' "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}"
   1139         IFS= read -r ans
   1140         setup_term; hide_cursor
   1141         case "$ans" in
   1142             y|Y)
   1143                 if [ -d "$target" ]; then
   1144                     if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then
   1145                         restore_term; show_cursor
   1146                         goto "$(term_rows)" 1
   1147                         printf '%s%s "%s" not empty. Delete ALL? [y/N] %s' \
   1148                             "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}"
   1149                         IFS= read -r ans2
   1150                         setup_term; hide_cursor
   1151                         case "$ans2" in
   1152                             y|Y) rm -rf "$target" ;;
   1153                             *)   NEED_FULL_REDRAW=1; return ;;
   1154                         esac
   1155                     else
   1156                         rm -rf "$target"
   1157                     fi
   1158                 else
   1159                     rm -f "$target"
   1160                 fi
   1161                 [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
   1162                 [ "$SEL" -lt 0 ] && SEL=0
   1163                 load_entries
   1164                 ;;
   1165             *) NEED_FULL_REDRAW=1 ;;
   1166         esac
   1167     fi
   1168 }
   1169 
   1170 do_rename() {
   1171     entry=$(get_entry "$SEL")
   1172     goto "$(term_rows)" 1
   1173     printf '%s%s Rename "%s" to (esc=cancel): %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "$entry" "${RESET}"
   1174     if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$entry" ]; then
   1175         mv "${CWD}/${entry%/}" "${CWD}/${READ_LINE}"
   1176         load_entries
   1177     else
   1178         NEED_FULL_REDRAW=1; draw
   1179     fi
   1180 }
   1181 
   1182 do_mkdir() {
   1183     goto "$(term_rows)" 1
   1184     printf '%s%s New dir name (esc=cancel): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
   1185     if read_line && [ -n "$READ_LINE" ]; then
   1186         mkdir -p "${CWD}/${READ_LINE}"
   1187         load_entries
   1188     else
   1189         NEED_FULL_REDRAW=1; draw
   1190     fi
   1191 }
   1192 
   1193 do_info() {
   1194     entry=$(get_entry "$SEL")
   1195     [ -z "$entry" ] && return
   1196     target="${CWD}/${entry%/}"
   1197     # permissions + type
   1198     _perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}')
   1199     # size (human readable via du, fallback to ls)
   1200     _size=$(du -sh "$target" 2>/dev/null | cut -f1)
   1201     [ -z "$_size" ] && _size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}')
   1202     # modification date
   1203     _date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}')
   1204     INFO_MSG="${_perm}  ${_size}  ${_date}"
   1205     NEED_FULL_REDRAW=1
   1206 }
   1207 
   1208 do_shell() {
   1209     restore_term; show_cursor
   1210     printf '\033[2J\033[H'
   1211     cd "$CWD" || true
   1212     printf '%s(type "exit" to return to fm)%s\n' "${YELLOW}" "${RESET}"
   1213     ${SHELL:-sh}
   1214     # restore CWD in case user cd'd around
   1215     CWD="${PWD}"; norm_cwd
   1216     setup_term; hide_cursor
   1217     NEED_FULL_REDRAW=1
   1218 }
   1219 
   1220 do_sort() {
   1221     case "$SORT_MODE" in
   1222         name) SORT_MODE="size" ;;
   1223         size) SORT_MODE="date" ;;
   1224         date) SORT_MODE="name" ;;
   1225     esac
   1226     INFO_MSG="sort: ${SORT_MODE}"
   1227     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1228     load_entries
   1229 }
   1230 
   1231 do_trash() {
   1232     entry=$(get_entry "$SEL")
   1233     [ -z "$entry" ] && return
   1234     target="${CWD}/${entry%/}"
   1235     _ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s')
   1236     _dest="${TRASH_DIR}/${_ts}_${entry%/}"
   1237     if mv "$target" "$_dest"; then
   1238         INFO_MSG="trashed: ${entry}"
   1239         [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
   1240         [ "$SEL" -lt 0 ] && SEL=0
   1241         load_entries
   1242     else
   1243         INFO_MSG="trash failed"
   1244         NEED_FULL_REDRAW=1
   1245     fi
   1246 }
   1247 
   1248 do_open_trash() {
   1249     PREV_CWD="$CWD"
   1250     FILTER=""; SELECTED=""
   1251     CWD="$TRASH_DIR"; norm_cwd
   1252     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1253     load_entries
   1254 }
   1255 
   1256 do_copy_path() {
   1257     entry=$(get_entry "$SEL")
   1258     [ -z "$entry" ] && return
   1259     _path="${CWD}/${entry%/}"
   1260     if command -v wl-copy  >/dev/null 2>&1; then
   1261         printf '%s' "$_path" | wl-copy
   1262     elif command -v xclip  >/dev/null 2>&1; then
   1263         printf '%s' "$_path" | xclip -selection clipboard
   1264     elif command -v xsel   >/dev/null 2>&1; then
   1265         printf '%s' "$_path" | xsel --clipboard --input
   1266     elif command -v pbcopy >/dev/null 2>&1; then
   1267         printf '%s' "$_path" | pbcopy
   1268     else
   1269         INFO_MSG="no clipboard tool found"
   1270         NEED_FULL_REDRAW=1
   1271         return
   1272     fi
   1273     INFO_MSG="path copied: ${_path}"
   1274     NEED_FULL_REDRAW=1
   1275 }
   1276 
   1277 do_jump_back() {
   1278     [ -z "$PREV_CWD" ] && { INFO_MSG="no previous directory"; NEED_FULL_REDRAW=1; return; }
   1279     _tmp="$CWD"
   1280     CWD="$PREV_CWD"; norm_cwd
   1281     PREV_CWD="$_tmp"
   1282     FILTER=""; SELECTED=""
   1283     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1284     load_entries
   1285 }
   1286 
   1287 do_bookmark_add() {
   1288     # toggle: if CWD already bookmarked, remove it; else add it
   1289     if grep -qx "$CWD" "$BOOKMARK_FILE" 2>/dev/null; then
   1290         # remove
   1291         _tmp=$(grep -vx "$CWD" "$BOOKMARK_FILE")
   1292         printf '%s\n' "$_tmp" > "$BOOKMARK_FILE"
   1293         INFO_MSG="bookmark removed: ${CWD}"
   1294     else
   1295         printf '%s\n' "$CWD" >> "$BOOKMARK_FILE"
   1296         INFO_MSG="bookmarked: ${CWD}"
   1297     fi
   1298     NEED_FULL_REDRAW=1
   1299 }
   1300 
   1301 do_bookmark_jump() {
   1302     # count bookmarks
   1303     _bc=0
   1304     while IFS= read -r _l; do [ -n "$_l" ] && _bc=$((_bc+1)); done < "$BOOKMARK_FILE"
   1305     [ "$_bc" -eq 0 ] && { INFO_MSG="no bookmarks saved"; NEED_FULL_REDRAW=1; return; }
   1306 
   1307     # draw a small picker overlay
   1308     ROWS=$(term_rows); COLS=$(term_cols)
   1309     _pw=$(( COLS * 2 / 3 )); [ "$_pw" -gt 70 ] && _pw=70; [ "$_pw" -lt 30 ] && _pw=30
   1310     _ph=$(( _bc + 4 )); [ "$_ph" -gt $((ROWS - 4)) ] && _ph=$((ROWS - 4))
   1311     _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1
   1312     _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1
   1313     _iw=$((_pw - 2))
   1314     _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-')
   1315 
   1316     _bsel=1
   1317     while true; do
   1318         # draw picker inline
   1319         goto "$_py" "$_px"
   1320         printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
   1321         goto "$((_py+1))" "$_px"
   1322         _t="  BOOKMARKS"; _pl=$((_iw - ${#_t})); _p=$(printf '%*s' "$_pl" '')
   1323         printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_t" "$_p" "${BOLD}${CYAN}" "${RESET}"
   1324         goto "$((_py+2))" "$_px"
   1325         printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
   1326         _bi=0
   1327         while IFS= read -r _bm; do
   1328             [ -z "$_bm" ] && continue
   1329             _bi=$((_bi+1))
   1330             goto "$((_py+2+_bi))" "$_px"
   1331             _bt="${_bi}  ${_bm}"
   1332             if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi
   1333             _bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '')
   1334             if [ "$_bi" -eq "$_bsel" ]; then
   1335                 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_bt" "$_bp" "${RESET}${BOLD}${CYAN}" "${RESET}"
   1336             else
   1337                 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_bt" "$_bp" "${BOLD}${CYAN}" "${RESET}"
   1338             fi
   1339         done < "$BOOKMARK_FILE"
   1340         goto "$((_py+_ph))" "$_px"
   1341         printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
   1342         IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk
   1343         case "$_bk" in
   1344             j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;;
   1345             k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;;
   1346             "$(printf '\033')")
   1347                 # use timeout to distinguish bare esc from arrow sequences
   1348                 IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq=""
   1349                 case "$_seq" in
   1350                     '[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;;
   1351                     '[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;;
   1352                     '[C') # right — open
   1353                         _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE")
   1354                         if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then
   1355                             PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
   1356                             FILTER=""; SELECTED=""
   1357                             SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1358                             load_entries
   1359                         else
   1360                             INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1
   1361                         fi
   1362                         return ;;
   1363                     '[D') NEED_FULL_REDRAW=1; return ;; # left — close
   1364                     *)    NEED_FULL_REDRAW=1; return ;; # bare esc — close
   1365                 esac ;;
   1366             "$(printf '\n')"|\
   1367             "$(printf '\r')"|\
   1368             l)
   1369                 _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE")
   1370                 if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then
   1371                     PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
   1372                     FILTER=""; SELECTED=""
   1373                     SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
   1374                     load_entries
   1375                 else
   1376                     INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1
   1377                 fi
   1378                 return ;;
   1379             q|Q|h) NEED_FULL_REDRAW=1; return ;;
   1380         esac
   1381     done
   1382 }
   1383 
   1384 do_toggle_select() {
   1385     entry=$(get_entry "$SEL")
   1386     [ -z "$entry" ] && return
   1387     toggle_selected "$entry"
   1388     _c=$(count_selected)
   1389     [ "$_c" -eq 0 ] && INFO_MSG="selection cleared" || INFO_MSG="${_c} selected"
   1390     SEL=$((SEL + 1))
   1391     NEED_FULL_REDRAW=1
   1392 }
   1393 
   1394 do_select_all() {
   1395     _c=$(count_selected)
   1396     if [ "$_c" -gt 0 ]; then
   1397         # deselect all
   1398         SELECTED=""
   1399         INFO_MSG="selection cleared"
   1400     else
   1401         # select all visible entries (skip ../)
   1402         SELECTED=""
   1403         _rest="$ENTRIES"
   1404         while [ -n "$_rest" ]; do
   1405             _line="${_rest%%
   1406 *}"; _next="${_rest#*
   1407 }"
   1408             [ "$_next" = "$_rest" ] && _next=""
   1409             _rest="$_next"
   1410             [ -z "$_line" ] && continue
   1411             [ "$_line" = "../" ] && continue
   1412             if [ -z "$SELECTED" ]; then SELECTED="$_line"
   1413             else SELECTED="$SELECTED
   1414 $_line"; fi
   1415         done
   1416         _c=$(count_selected)
   1417         INFO_MSG="selected all: ${_c} items"
   1418     fi
   1419     NEED_FULL_REDRAW=1
   1420 }
   1421 
   1422 do_yank() {
   1423     _c=$(count_selected)
   1424     if [ "$_c" -gt 0 ]; then
   1425         CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
   1426             [ -z "$_e" ] && continue
   1427             printf '%s\n' "${CWD}/${_e%/}"
   1428         done)
   1429         CLIP_MODE="copy"
   1430         INFO_MSG="yanked ${_c} items"
   1431         SELECTED=""
   1432     else
   1433         entry=$(get_entry "$SEL")
   1434         [ -z "$entry" ] && return
   1435         CLIPBOARD="${CWD}/${entry%/}"
   1436         CLIP_MODE="copy"
   1437         INFO_MSG="yanked: ${entry}"
   1438     fi
   1439     NEED_FULL_REDRAW=1
   1440 }
   1441 
   1442 do_cut() {
   1443     _c=$(count_selected)
   1444     if [ "$_c" -gt 0 ]; then
   1445         CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
   1446             [ -z "$_e" ] && continue
   1447             printf '%s\n' "${CWD}/${_e%/}"
   1448         done)
   1449         CLIP_MODE="cut"
   1450         INFO_MSG="cut ${_c} items"
   1451         SELECTED=""
   1452     else
   1453         entry=$(get_entry "$SEL")
   1454         [ -z "$entry" ] && return
   1455         CLIPBOARD="${CWD}/${entry%/}"
   1456         CLIP_MODE="cut"
   1457         INFO_MSG="cut: ${entry}"
   1458     fi
   1459     NEED_FULL_REDRAW=1
   1460 }
   1461 
   1462 do_paste() {
   1463     if [ -z "$CLIPBOARD" ]; then
   1464         INFO_MSG="nothing to paste"
   1465         NEED_FULL_REDRAW=1
   1466         return
   1467     fi
   1468     _ok=0; _fail=0
   1469     printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do
   1470         [ -z "$_src" ] && continue
   1471         _name="${_src##*/}"
   1472         _dest="${CWD}/${_name}"
   1473         if [ -e "$_dest" ]; then
   1474             _base="${_name%.*}"; _ext="${_name##*.}"
   1475             [ "$_ext" = "$_name" ] && _ext="" || _ext=".${_ext}"
   1476             _dest="${CWD}/${_base}_copy${_ext}"
   1477         fi
   1478         case "$CLIP_MODE" in
   1479             copy) cp -r "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;;
   1480             cut)  mv    "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;;
   1481         esac
   1482     done
   1483     CLIPBOARD=""; CLIP_MODE=""
   1484     INFO_MSG="pasted"
   1485     load_entries
   1486 }
   1487 
   1488 # --- main ---
   1489 trap 'restore_term; show_cursor; printf "\033[2J\033[H"; exit 0' INT TERM EXIT
   1490 
   1491 setup_term
   1492 hide_cursor
   1493 printf '\033[2J'   # clear screen exactly once at startup
   1494 load_entries
   1495 
   1496 while true; do
   1497     draw
   1498     key=$(read_key)
   1499 
   1500     case "$key" in
   1501         j|"$(printf '\033[B')")
   1502             [ "$SEL" -lt $((COUNT - 1)) ] && SEL=$((SEL + 1)) ;;
   1503         k|"$(printf '\033[A')")
   1504             [ "$SEL" -gt 0 ] && SEL=$((SEL - 1)) ;;
   1505         g)  SEL=0 ;;
   1506         G)  SEL=$((COUNT - 1)) ;;
   1507         "$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;;
   1508         "$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;;
   1509         "$(printf '\n')"|\
   1510         "$(printf '\r')"|\
   1511         "$(printf '\033[C')"|\
   1512         l) do_open    ;;
   1513         "$(printf '\033[D')"|\
   1514         h) do_go_back ;;
   1515         b)   do_bookmark_add  ;;
   1516         B)   do_bookmark_jump ;;
   1517         '?') do_help          ;;
   1518         R)   load_entries; INFO_MSG="refreshed" ;;
   1519         /)   do_search        ;;
   1520         '.')  do_toggle_hidden   ;;
   1521         T)    do_toggle_details  ;;
   1522         P)    do_toggle_preview  ;;
   1523         i)   do_info          ;;
   1524         '!') do_shell         ;;
   1525         o)   do_open_with     ;;
   1526         s)   do_sort          ;;
   1527         u)   do_trash        ;;
   1528         U)   do_open_trash   ;;
   1529         '`') do_jump_back    ;;
   1530         '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED=""
   1531              SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;;
   1532         c)   do_copy_path   ;;
   1533         "$(printf '\033')") do_clear_filter ;;
   1534         ' ') do_toggle_select ;;
   1535         a)   do_select_all   ;;
   1536         y)  do_yank    ;;
   1537         x)  do_cut     ;;
   1538         p)  do_paste   ;;
   1539         d)  do_delete  ;;
   1540         r)  do_rename  ;;
   1541         m)  do_mkdir   ;;
   1542         n)  do_newfile ;;
   1543         q|Q) break ;;
   1544     esac
   1545 done
   1546 
   1547 restore_term
   1548 show_cursor
   1549 printf '\033[2J\033[H'
   1550 printf '%s\n' "$CWD"