#!/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' "${GREEN}${BOLD}"  ; return; }
    # node device?
    [ -c "${CWD}/${_fc_name}" ] && { printf '%s' "${MAGENTA}${BOLD}"; return; }
    # block device?
    [ -b "${CWD}/${_fc_name}" ] && { printf '%s' "${MAGENTA}${BOLD}"; return; }
    # readable?
    [ -r "${CWD}/${_fc_name}" ] || { printf '%s' "${RED}${BOLD}"    ; return; }
    # writable?
    [ -w "${CWD}/${_fc_name}" ] || { printf '%s' "${RED}${BOLD}"    ; return; }
}

goto()        { printf '\033[%d;%dH' "$1" "$2"; }
hide_cursor() { printf '\033[?25l'; }
show_cursor() { printf '\033[?25h'; }
term_rows()   { tput_cmd lines || echo 24; }
term_cols()   { tput_cmd cols  || echo 80; }

# --- state ---
if [ -n "$1" ]; then
    CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; }
else
    CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/"
fi
SEL=0
OFFSET=0
PREV_SEL=0
PREV_OFFSET=0
NEED_FULL_REDRAW=1
LAST_CHILD=""   # name of dir we came from, used to restore selection on go-back
FILTER=""       # current search string; empty = no filter
ALL_ENTRIES=""  # unfiltered entries
ALL_COUNT=0
SEARCHING=0     # 1 while in search input mode
SELECTED=""     # newline-separated list of multi-selected entry names
CLIPBOARD=""    # full path of yanked/cut entry
CLIP_MODE=""    # "copy" or "cut"
INFO_MSG=""     # ephemeral message shown in botbar
SHOW_HIDDEN=0   # 1 = show dotfiles
SORT_MODE="name" # name | size | date
SHOW_DETAILS=0   # 1 = show size+date column
SHOW_PREVIEW=0   # 1 = show preview pane on right
TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash"
mkdir -p "$TRASH_DIR"
PREV_CWD=""     # last visited directory, for jumping back

# normalise CWD: resolve to absolute canonical path, no double slashes
norm_cwd() {
    CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/"
    # squeeze any double slashes (some systems return // for //<path>)
    CWD=$(printf '%s' "$CWD" | tr -s '/')
}

# safely join CWD with a name, avoiding double slash at root
joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; }
BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks"
mkdir -p "$(dirname "$BOOKMARK_FILE")"
[ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE"

# --- load directory ---
load_entries() {
    ENTRIES=""
    COUNT=0

    # hidden dirs first
    if [ "$SHOW_HIDDEN" = "1" ]; then
        for d in "$CWD"/.*/; do
            [ -d "$d" ] || continue
            [ -L "${d%/}" ] && continue
            name="${d%/}"; name="${name##*/}"
            [ "$name" = "." ] || [ "$name" = ".." ] && continue
            [ "$name" = ".*" ] && continue
            ENTRIES="$ENTRIES
${name}/"
            COUNT=$((COUNT + 1))
        done
    fi

    # regular dirs
    for d in "$CWD"/*/; do
        [ -d "$d" ] || continue
        [ -L "${d%/}" ] && continue
        name="${d%/}"; name="${name##*/}"
        [ "$name" = "*" ] && continue
        case "$name" in .*) continue ;; esac
        ENTRIES="$ENTRIES
${name}/"
        COUNT=$((COUNT + 1))
    done

    # hidden files first
    if [ "$SHOW_HIDDEN" = "1" ]; then
        for f in "$CWD"/.*; do
            [ -e "$f" ] || [ -L "$f" ] || continue
            [ -d "$f" ] && ! [ -L "$f" ] && continue
            name="${f##*/}"
            [ "$name" = ".*" ] && continue
            if [ -L "$f" ]; then
                ENTRIES="$ENTRIES
${name}@"
            else
                ENTRIES="$ENTRIES
${name}"
            fi
            COUNT=$((COUNT + 1))
        done
    fi

    # regular files
    for f in "$CWD"/*; do
        [ -e "$f" ] || [ -L "$f" ] || continue
        [ -d "$f" ] && ! [ -L "$f" ] && continue
        name="${f##*/}"
        [ "$name" = "*" ] && continue
        case "$name" in .*) continue ;; esac
        if [ -L "$f" ]; then
            ENTRIES="$ENTRIES
${name}@"
        else
            ENTRIES="$ENTRIES
${name}"
        fi
        COUNT=$((COUNT + 1))
    done

    ENTRIES="${ENTRIES#
}"

    # --- sort files only (dirs always stay first) ---
    if [ "$SORT_MODE" != "name" ]; then
        # separate dirs and files
        _dirs=""; _files=""
        _rest="$ENTRIES"
        while [ -n "$_rest" ]; do
            _line="${_rest%%
*}"; _next="${_rest#*
}"
            [ "$_next" = "$_rest" ] && _next=""
            _rest="$_next"
            [ -z "$_line" ] && continue
            case "$_line" in
                */)  if [ -z "$_dirs" ]; then _dirs="$_line"
                     else _dirs="$_dirs
$_line"; fi ;;
                *)   if [ -z "$_files" ]; then _files="$_line"
                     else _files="$_files
$_line"; fi ;;
            esac
        done

        # sort files using ls into a tmp file
        if [ -n "$_files" ]; then
            case "$SORT_MODE" in
                size) ls -1Sp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;;
                date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted ;;
            esac
            # only keep files that were already in our list (respects hidden filter)
            _sorted=""
            while IFS= read -r _n; do
                [ -z "$_n" ] && continue
                # check if _n is in _files
                case "
${_files}
" in *"
${_n}
"*) if [ -z "$_sorted" ]; then _sorted="$_n"
                    else _sorted="$_sorted
$_n"; fi ;;
                esac
            done < /tmp/_fm_sorted
            _files="$_sorted"
        fi

        # reassemble: dirs + sorted files
        ENTRIES=""
        _rest="$_dirs"
        while [ -n "$_rest" ]; do
            _line="${_rest%%
*}"; _next="${_rest#*
}"
            [ "$_next" = "$_rest" ] && _next=""
            _rest="$_next"
            [ -z "$_line" ] && continue
            if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
            else ENTRIES="$ENTRIES
$_line"; fi
        done
        _rest="$_files"
        while [ -n "$_rest" ]; do
            _line="${_rest%%
*}"; _next="${_rest#*
}"
            [ "$_next" = "$_rest" ] && _next=""
            _rest="$_next"
            [ -z "$_line" ] && continue
            if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
            else ENTRIES="$ENTRIES
$_line"; fi
        done
        # recount
        COUNT=0
        _rest="$ENTRIES"
        while [ -n "$_rest" ]; do
            _line="${_rest%%
*}"; _next="${_rest#*
}"
            [ "$_next" = "$_rest" ] && _next=""
            _rest="$_next"
            [ -n "$_line" ] && COUNT=$((COUNT + 1))
        done
    fi

    ALL_ENTRIES="$ENTRIES"
    ALL_COUNT="$COUNT"
    apply_filter
    NEED_FULL_REDRAW=1
}

# filter ALL_ENTRIES by FILTER string, update ENTRIES/COUNT
apply_filter() {
    if [ -z "$FILTER" ]; then
        ENTRIES="$ALL_ENTRIES"
        COUNT="$ALL_COUNT"
    else
        ENTRIES=""
        COUNT=0
        _rest="$ALL_ENTRIES"
        while [ -n "$_rest" ]; do
            _line="${_rest%%
*}"; _next="${_rest#*
}"
            [ "$_next" = "$_rest" ] && _next=""
            _rest="$_next"
            [ -z "$_line" ] && continue
            case "$_line" in
                *"$FILTER"*)
                    if [ -z "$ENTRIES" ]; then ENTRIES="$_line"
                    else ENTRIES="$ENTRIES
$_line"; fi
                    COUNT=$((COUNT + 1)) ;;
            esac
        done
    fi
    # write to tmp file for O(1) line access in get_entry
    printf '%s\n' "$ENTRIES" > /tmp/_sfm_list
}

get_entry() {
    sed -n "$(($1 + 1))p" /tmp/_sfm_list
}

# find index of entry by name (0-based), returns -1 if not found
find_entry() {
    _fe_n=$(grep -n "^${1}$" /tmp/_sfm_list 2>/dev/null | head -1 | cut -d: -f1)
    if [ -n "$_fe_n" ]; then
        printf '%d' $((_fe_n - 1))
    else
        printf '%d' -1
    fi
}

# is entry name in SELECTED list?
is_selected() {
    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="${BLUE}${BOLD}" ;;
        *@)
            _name="${entry%@}"
            if [ -e "${CWD}/${_name}" ]; then
                colour="${CYAN}${BOLD}"
            else
                colour="${RED}${BOLD}"
            fi ;;
        *)
            _fc="${CWD}/${entry}"
            if   [ -x "$_fc" ]; then colour="${GREEN}${BOLD}"
            elif [ -c "$_fc" ]; then colour="${MAGENTA}${BOLD}"
            elif [ -b "$_fc" ]; then colour="${MAGENTA}${BOLD}"
            elif [ ! -r "$_fc" ] || [ ! -w "$_fc" ]; then colour="${RED}${BOLD}"
            else colour="${WHITE}"
            fi ;;
    esac

    # multi-select marker
    if is_selected "$entry"; then
        _marker="${YELLOW}*${colour}"
    else
        _marker=" "
    fi

    # build display string
    case "$entry" in
        *@)
            _name="${entry%@}"
            _target=$(readlink "${CWD}/${_name}" 2>/dev/null || echo "?")
            # mark broken symlinks clearly
            if [ ! -e "${CWD}/${_name}" ]; then
                display="${_name} -> ${_target} [broken]"
            else
                display="${_name} -> ${_target}"
            fi
            ;;
        *)
            display="${entry}"
            ;;
    esac

    # build detail string (size + date) if enabled — fixed width for alignment
    _detail=""
    if [ "$SHOW_DETAILS" = "1" ]; then
        _path="${CWD}/${entry%/}"; _path="${_path%@}"
        _info=$(ls -ldh "$_path" 2>/dev/null)
        # parse ls output with parameter expansion — no awk fork
        set -- $_info
        _sz=$5; _dt="$6 $7"
        _detail=$(printf ' %6s  %-6s' "$_sz" "$_dt")
        set --
    fi

    # layout: marker(1) + name + spaces + detail
    _dlen=${#_detail}
    maxw=$((_cols - 2 - _dlen))
    if [ "${#display}" -gt "$maxw" ]; then
        _trunc=$((maxw - 3))
        while [ "${#display}" -gt "$_trunc" ]; do
            display="${display%?}"
        done
        display="${display}..."
    fi
    _namew=$((${#display} + 1))   # 1 for marker
    padlen=$((_cols - _namew - _dlen))
    [ "$padlen" -lt 0 ] && padlen=0
    spaces=$(printf '%*s' "$padlen" '')

    goto "$_row" 1
    if [ "$_selected" = "1" ]; then
        printf '%s%s%s%s%s%s%s' \
            "${REV}${colour}" "${_marker}" "${display}" \
            "${spaces}" \
            "${colour}${_detail}" \
            "${RESET}" ""
    else
        printf '%s%s%s%s%s%s%s' \
            "${colour}" "${_marker}" "${display}" \
            "${spaces}" \
            "${colour}${_detail}" \
            "${RESET}" ""
    fi
}

draw_preview() {
    _rows=$1; _cols=$2; _px=$3; _pw=$4
    entry=$(get_entry "$SEL")
    # clear preview area first
    _r=2
    while [ "$_r" -le $((_rows - 1)) ]; do
        goto "$_r" "$_px"
        printf '\033[K'
        _r=$((_r + 1))
    done
    [ -z "$entry" ] && return
    _path=$(joinpath "${entry%/}"); _path="${_path%@}"

    # draw vertical divider
    _r=2
    while [ "$_r" -le $((_rows - 1)) ]; do
        goto "$_r" $((_px - 1))
        printf '%s|%s' "${CYAN}" "${RESET}"
        _r=$((_r + 1))
    done

    case "$entry" in
        */)
            # directory: list contents
            _max_lines=$((_rows - 3))
            _pr=2
            for _de in "$_path"/*/; do
                [ "$_pr" -gt $((_rows - 1)) ] && break
                [ -d "$_de" ] || continue
                _dn="${_de%/}"; _dn="${_dn##*/}"
                [ "$_dn" = "*" ] && continue
                _dl=" ${_dn}/"
                [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~"
                goto "$_pr" "$_px"
                printf '%s%s%s' "${BLUE}${BOLD}" "$_dl" "${RESET}"
                _pr=$((_pr + 1))
            done
            for _fe in "$_path"/*; do
                [ "$_pr" -gt $((_rows - 1)) ] && break
                [ -e "$_fe" ] || continue
                [ -d "$_fe" ] && continue
                _fn="${_fe##*/}"
                [ "$_fn" = "*" ] && continue
                _fl=" ${_fn}"
                [ "${#_fl}" -gt "$((_pw - 1))" ] && _fl="$(printf '%s' "$_fl" | cut -c1-$((_pw-2)))~"
                goto "$_pr" "$_px"
                printf '%s%s%s' "${WHITE}" "$_fl" "${RESET}"
                _pr=$((_pr + 1))
            done
            if [ "$_pr" -eq 2 ]; then
                goto 2 "$_px"
                printf '%s(empty)%s' "${WHITE}" "${RESET}"
            fi
            ;;
        *@)
            # symlink
            _tgt=$(readlink "$_path" 2>/dev/null || echo "?")
            goto 2 "$_px"
            printf '%s[symlink]%s' "${MAGENTA}${BOLD}" "${RESET}"
            goto 3 "$_px"
            printf '%s-> %s%s' "${WHITE}" "${_tgt}" "${RESET}"
            ;;
        *)
            # detect if text via extension or mime
            _ext="${entry##*.}"
            _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')
            _is_text=0
            case "$_ext" in
                txt|md|markdown|rst|log|conf|cfg|ini|toml|yaml|yml|\
                sh|bash|zsh|py|rb|pl|lua|js|ts|json|xml|html|htm|\
                css|c|h|cpp|rs|go|java|php|r|sql|vim|env|gitignore|\
                diff|patch|csv|tsv|lock|mod|sum) _is_text=1 ;;
            esac
            if [ "$_is_text" = "0" ] && command -v file >/dev/null 2>&1; then
                case "$(file --mime-type -b "$_path" 2>/dev/null)" in
                    text/*) _is_text=1 ;;
                esac
            fi
            if [ "$_is_text" = "1" ]; then
                _max_lines=$((_rows - 3))
                _line_n=0
                while IFS= read -r _line && [ "$_line_n" -lt "$_max_lines" ]; do
                    # truncate to preview width
                    if [ "${#_line}" -gt "$((_pw - 1))" ]; then
                        _line=$(printf '%s' "$_line" | cut -c1-$((_pw - 2)))
                        _line="${_line}~"
                    fi
                    goto "$((_line_n + 2))" "$_px"
                    printf '%s%s%s' "${WHITE}" "$_line" "${RESET}"
                    _line_n=$((_line_n + 1))
                done < "$_path"
                if [ "$_line_n" -eq 0 ]; then
                    goto 2 "$_px"
                    printf '%s(empty file)%s' "${WHITE}" "${RESET}"
                fi
            else
                # binary / unknown
                goto 2 "$_px"
                _sz=$(ls -lh "$_path" 2>/dev/null | awk '{print $5}')
                printf '%s[binary] %s%s' "${YELLOW}" "${_sz}" "${RESET}"
            fi
            ;;
    esac
}

draw_topbar() {
    _cols=$1
    spaces=$(printf '%*s' "$_cols" '')
    goto 1 1
    printf '%s%s%s' "${CYAN}${BOLD}" "${spaces}" "${RESET}"
}

draw_botbar() {
    _rows=$1; _cols=$2
    if [ "$SEARCHING" = "1" ]; then
        info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}"
        keys=" search: ${FILTER} "
        maxk=$((_cols - ${#info} - 1))
        [ "${#keys}" -gt "$maxk" ] && keys=$(printf '%s' "$keys" | cut -c1-"$maxk")
        padlen=$((_cols - ${#info} - ${#keys}))
        [ "$padlen" -lt 0 ] && padlen=0
        pad=$(printf '%*s' "$padlen" '')
        goto "$_rows" 1
        printf '%s%s%s%s%s%s' \
            "${BOLD}${CYAN}" "${info}" \
            "${RESET}${pad}" \
            "${YELLOW}${keys}" \
            "${RESET}"
    elif [ -n "$INFO_MSG" ]; then
        info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}"
        msg=" ${INFO_MSG} "        padlen=$((_cols - ${#info} - ${#msg}))
        [ "$padlen" -lt 0 ] && padlen=0
        pad=$(printf '%*s' "$padlen" '')
        goto "$_rows" 1
        printf '%s%s%s%s%s%s' \
            "${BOLD}${CYAN}" "${info}" \
            "${RESET}${pad}" \
            "${YELLOW}${msg}" \
            "${RESET}"
        INFO_MSG=""
    else
        _ind=""
        [ -n "$CLIPBOARD" ]        && _ind=" [${CLIP_MODE}]"
        _sc=$(count_selected)
        [ "$_sc" -gt 0 ]           && _ind="${_ind} [sel:${_sc}]"
        [ "$SHOW_HIDDEN" = "1" ]   && _ind="${_ind} [hidden]"
        [ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]"
        [ "$SHOW_DETAILS" = "1" ]  && _ind="${_ind} [details]"
        hint="  press ? for help "
        info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}${_ind}"
        padlen=$((_cols - ${#info} - ${#hint}))
        [ "$padlen" -lt 0 ] && padlen=0
        pad=$(printf '%*s' "$padlen" '')
        goto "$_rows" 1
        printf '%s%s%s%s%s%s' \
            "${BOLD}${CYAN}" "${info}" \
            "${RESET}${pad}" \
            "${YELLOW}${hint}" \
            "${RESET}"
    fi
}

draw() {
    ROWS=$(term_rows)
    COLS=$(term_cols)
    LIST_ROWS=$((ROWS - 2))   # row 1 = topbar, row ROWS = botbar

    # split layout when preview enabled
    if [ "$SHOW_PREVIEW" = "1" ]; then
        LIST_COLS=$((COLS / 2))
        PREV_COL=$((LIST_COLS + 2))
        PREV_WIDTH=$((COLS - LIST_COLS - 2))
    else
        LIST_COLS=$COLS
    fi

    # clamp selection
    [ "$SEL" -lt 0 ] && SEL=0
    [ "$COUNT" -gt 0 ] && [ "$SEL" -ge "$COUNT" ] && SEL=$((COUNT - 1))

    # update scroll offset
    if [ "$SEL" -lt "$OFFSET" ]; then
        OFFSET=$SEL
    elif [ "$SEL" -ge $((OFFSET + LIST_ROWS)) ]; then
        OFFSET=$((SEL - LIST_ROWS + 1))
    fi

    # viewport shifted → must redraw all rows
    [ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1

    if [ "$NEED_FULL_REDRAW" = "1" ]; then
        printf '\033[H'
        draw_topbar "$COLS"

        row=2
        idx=$OFFSET
        while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do
            sel=0; [ "$idx" -eq "$SEL" ] && sel=1
            render_row "$row" "$idx" "$sel" "$LIST_COLS"
            row=$((row + 1))
            idx=$((idx + 1))
        done
        # show (empty) if directory has no entries
        if [ "$COUNT" -eq 0 ]; then
            goto 2 1
            printf '%s  (empty)%s%s' "${WHITE}" "${ERASE_LINE}" "${RESET}"
            row=3
        fi
        # blank leftover rows
        while [ "$row" -le $((LIST_ROWS + 1)) ]; do
            goto "$row" 1
            printf '%s' "${ERASE_LINE}"
            row=$((row + 1))
        done

        [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"

        draw_botbar "$ROWS" "$COLS"
        NEED_FULL_REDRAW=0
    else
        # --- fast path ---
        if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then
            return 2>/dev/null || true
        fi
        prev_row=$(( (PREV_SEL - OFFSET) + 2 ))
        new_row=$(( (SEL      - OFFSET) + 2 ))

        if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then
            render_row "$prev_row" "$PREV_SEL" 0 "$LIST_COLS"
        fi
        if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then
            render_row "$new_row" "$SEL" 1 "$LIST_COLS"
        fi

        [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH"

        draw_botbar "$ROWS" "$COLS"
    fi

    PREV_SEL=$SEL
    PREV_OFFSET=$OFFSET
}

# --- input ---
read_key() {
    IFS= read -r -n1 key 2>/dev/null || IFS= read -r key
    if [ "$key" = "$(printf '\033')" ]; then
        IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2=""
        if [ -z "$_k2" ]; then
            printf '\033'; return
        fi
        IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3=""
        # if sequence ends with ~, it may have more chars — read one more
        case "${_k2}${_k3}" in
            '['[0-9])
                IFS= read -r -n1 -t 0.1 _k4 2>/dev/null || _k4=""
                key="${key}${_k2}${_k3}${_k4}"
                ;;
            *)
                key="${key}${_k2}${_k3}"
                ;;
        esac
    fi
    printf '%s' "$key"
}

setup_term()   { old_stty=$(stty -g); stty -echo -icanon min 1 time 0; }
restore_term() { stty "$old_stty"; }

# --- actions ---
# --- smart file opener ---
# Detects file type by extension then mime, picks the right program.
# Terminal programs run in the foreground; GUI programs are backgrounded.
_run_tty() { "$@"; }
_run_gui() { "$@" >/dev/null 2>&1 & }

open_file() {
    _f="$1"

    # use user opener script if it exists and is executable
    _opener="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/opener"
    if [ -x "$_opener" ]; then
        "$_opener" "$_f"
        return
    fi

    _ext="${_f##*.}"
    _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]')

    # --- by extension ---
    case "$_ext" in
        # text / code → editor
        txt|md|markdown|rst|csv|tsv|log|conf|cfg|ini|toml|yaml|yml|\
        sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|json|xml|html|\
        htm|css|scss|sass|c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|\
        cs|php|r|sql|vim|diff|patch|makefile|dockerfile|gitignore|\
        env|lock|mod|sum)
            _run_tty ${EDITOR:-vi} "$_f"
            return ;;
        # images → GUI viewer
        jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif)
            for _v in imv imvr feh sxiv nsxiv eog eom viewnior shotwell gimp; do
                command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
            done ;;
        # video → GUI player
        mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv)
            for _v in mpv vlc mplayer totem celluloid haruna; do
                command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
            done ;;
        # audio → player (mpv/vlc run fine headless too)
        mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff)
            for _v in mpv vlc mplayer cmus mocp; do
                command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
            done ;;
        # PDF / documents
        pdf)
            for _v in zathura evince okular mupdf atril xreader; do
                command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
            done ;;
        # office docs
        odt|ods|odp|doc|docx|xls|xlsx|ppt|pptx)
            for _v in libreoffice soffice; do
                command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
            done ;;
        # archives → list contents in pager
        zip|tar|gz|bz2|xz|zst|7z|rar)
            if command -v atool >/dev/null 2>&1; then
                atool -l "$_f" 2>&1 | ${PAGER:-less}; return
            elif command -v bsdtar >/dev/null 2>&1; then
                bsdtar -tf "$_f" 2>&1 | ${PAGER:-less}; return
            fi ;;
    esac

    # --- fallback: try mime type via `file` ---
    if command -v file >/dev/null 2>&1; then
        _mime=$(file --mime-type -b "$_f" 2>/dev/null)
        case "$_mime" in
            text/*|application/json|application/xml|application/javascript)
                _run_tty ${EDITOR:-vi} "$_f"; return ;;
            image/*)
                for _v in imv feh sxiv nsxiv eog gimp; do
                    command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
                done ;;
            video/*)
                for _v in mpv vlc mplayer; do
                    command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
                done ;;
            audio/*)
                for _v in mpv vlc mplayer; do
                    command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
                done ;;
            application/pdf)
                for _v in zathura evince okular mupdf; do
                    command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; }
                done ;;
        esac
    fi

    # --- last resort: xdg-open / open, else editor ---
    if command -v xdg-open >/dev/null 2>&1; then
        _run_gui xdg-open "$_f"
    elif command -v open >/dev/null 2>&1; then
        _run_gui open "$_f"
    else
        _run_tty ${EDITOR:-vi} "$_f"
    fi
}

# read a line of input in raw mode, returns result in READ_LINE
# returns 1 if user pressed Esc (cancel), 0 on Enter
read_line() {
    READ_LINE=""
    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=46
    _help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1
    _help_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1
    _help_iw=$((bw - 2))
    _help_hl=$(printf '%*s' "$_help_iw" '' | tr ' ' '-')

    goto "$_help_by" "$_help_bx"
    printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"

    help_row  1 ""
    help_row  2 "  KEYBOARD SHORTCUTS"
    help_sep  3
    help_row  4 "  j/k  up/down     g/G  top/bottom"
    help_row  5 "  h/left           go back"
    help_row  6 "  l/right/enter    open / enter dir"
    help_sep  7
    help_row  8 "  /     search filter"
    help_row  9 "  esc   clear filter"
    help_row 10 "  .     toggle hidden files"
    help_row 11 "  i     file info in status bar"
    help_row 12 "  s     cycle sort: name/size/date"
    help_row 13 "  T     toggle size/date details"
    help_row 14 "  P     toggle preview pane"
    help_sep 15
    help_row 16 "  space  toggle multi-select"
    help_row 17 "  a      select all / deselect all"
    help_row 18 "  y      yank/copy  (works on selection)"
    help_row 19 "  x      cut        (works on selection)"
    help_row 20 "  p      paste"
    help_row 21 "  d      delete     (works on selection)"
    help_sep 22
    help_row 23 "  r   rename         R   refresh"
    help_row 24 "  m   make directory"
    help_row 25 "  n   new file"
    help_row 26 "  u   trash file (safe delete)"
    help_row 27 "  U   open trash directory"
    help_row 28 "  !   drop to shell in CWD"
    help_row 29 "  o   open with custom program"
    help_row 30 "  +   chmod +x (make executable)"
    help_row 31 "  -   chmod -x (remove executable)"
    help_sep 32
    help_row 33 "  b   bookmark current dir"
    help_row 34 "  B   open bookmark picker"
    help_row 35 "  c   copy path to clipboard"
    help_row 36 "  ~   go to home directory"
    help_row 37 "  \`   jump to previous directory"
    help_row 38 "  :   jump to path"
    help_row 39 "  f   find files recursively"
    help_sep 40
    help_row 41 "  q   quit"
    help_row 42 "  ?   this help"
    help_row 43 ""
    help_row 44 "  press any key to close..."

    goto "$(( _help_by + bh ))" "$_help_bx"
    printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"

    read_key > /dev/null
    NEED_FULL_REDRAW=1
}

do_search() {
    FILTER=""
    SEARCHING=1
    show_cursor
    while true; do
        apply_filter
        SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
        NEED_FULL_REDRAW=1
        draw
        IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch
        case "$_ch" in
            "$(printf '\033')")
                # read with short timeout to catch escape sequences
                IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq=""
                if [ -z "$_seq" ]; then
                    # bare esc — clear filter and exit search
                    FILTER=""; SEARCHING=0
                    apply_filter
                    SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
                    hide_cursor; NEED_FULL_REDRAW=1; draw; return
                fi
                # else it was an arrow key or other sequence — ignore it
                ;;
            "$(printf '\n')"|\
            "$(printf '\r')")
                SEARCHING=0; hide_cursor; NEED_FULL_REDRAW=1; return ;;
            "$(printf '\177')"|\
            "$(printf '\010')")
                [ -n "$FILTER" ] && FILTER="${FILTER%?}" ;;
            *)
                FILTER="${FILTER}${_ch}" ;;
        esac
    done
}
do_clear_filter() {
    FILTER=""
    SEARCHING=0
    apply_filter
    SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    NEED_FULL_REDRAW=1
}

do_toggle_hidden() {
    if [ "$SHOW_HIDDEN" = "0" ]; then
        SHOW_HIDDEN=1
        INFO_MSG="hidden files shown"
    else
        SHOW_HIDDEN=0
        INFO_MSG="hidden files hidden"
    fi
    SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    load_entries
}

do_toggle_details() {
    if [ "$SHOW_DETAILS" = "0" ]; then
        SHOW_DETAILS=1; INFO_MSG="details on"
    else
        SHOW_DETAILS=0; INFO_MSG="details off"
    fi
    NEED_FULL_REDRAW=1
}

do_toggle_preview() {
    if [ "$SHOW_PREVIEW" = "0" ]; then
        SHOW_PREVIEW=1; INFO_MSG="preview on"
    else
        SHOW_PREVIEW=0; INFO_MSG="preview off"
    fi
    NEED_FULL_REDRAW=1
}

do_find() {
    goto "$(term_rows)" 1
    printf '%s%s find (recursive): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
    if ! read_line || [ -z "$READ_LINE" ]; then
        NEED_FULL_REDRAW=1; draw; return
    fi
    _query="$READ_LINE"

    # run find and collect results into a tmp file
    _tmp=/tmp/_sfm_find
    find "$CWD" -name "*${_query}*" 2>/dev/null | sort > "$_tmp"
    _total=$(wc -l < "$_tmp" | tr -d '[:space:]')

    if [ "$_total" -eq 0 ]; then
        INFO_MSG="no results for: ${_query}"
        NEED_FULL_REDRAW=1; return
    fi

    # show results in an interactive picker
    ROWS=$(term_rows); COLS=$(term_cols)
    _pw=$(( COLS - 4 )); [ "$_pw" -gt 80 ] && _pw=80; [ "$_pw" -lt 30 ] && _pw=30
    _ph=$(( ROWS - 4 )); [ "$_ph" -lt 5 ] && _ph=5
    _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1
    _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1
    _iw=$((_pw - 2))
    _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-')
    _vis=$((_ph - 3))   # visible result rows

    _fsel=1
    _foff=1   # scroll offset (1-based)

    while true; do
        # draw box
        goto "$_py" "$_px"
        printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"

        # title
        goto "$((_py+1))" "$_px"
        _title="  find: ${_query}  (${_total} results)"
        [ "${#_title}" -gt "$_iw" ] && _title=$(printf '%s' "$_title" | cut -c1-"$_iw")
        _tpl=$((_iw - ${#_title})); _tp=$(printf '%*s' "$_tpl" '')
        printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_title" "$_tp" "${BOLD}${CYAN}" "${RESET}"

        goto "$((_py+2))" "$_px"
        printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"

        # results
        _ri=0
        while [ "$_ri" -lt "$_vis" ]; do
            _ridx=$((_foff + _ri))
            goto "$((_py+3+_ri))" "$_px"
            if [ "$_ridx" -le "$_total" ]; then
                _rline=$(awk -v n="$_ridx" 'NR==n{print;exit}' "$_tmp")
                # strip CWD prefix for display
                _rdisplay="${_rline#$CWD/}"
                [ "${#_rdisplay}" -gt "$_iw" ] && \
                    _rdisplay="...$(printf '%s' "$_rdisplay" | rev | cut -c1-$((_iw-4)) | rev)"
                _rpl=$((_iw - ${#_rdisplay})); _rp=$(printf '%*s' "$_rpl" '')
                if [ "$_ridx" -eq "$_fsel" ]; then
                    printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_rdisplay" "$_rp" "${RESET}${BOLD}${CYAN}" "${RESET}"
                else
                    printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_rdisplay" "$_rp" "${BOLD}${CYAN}" "${RESET}"
                fi
            else
                _ep=$(printf '%*s' "$_iw" '')
                printf '%s|%s|%s' "${BOLD}${CYAN}" "$_ep" "${RESET}"
            fi
            _ri=$((_ri+1))
        done

        goto "$((_py+_ph))" "$_px"
        printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"

        # read key
        IFS= read -r -n1 _fk 2>/dev/null || IFS= read -r _fk
        case "$_fk" in
            j) _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total
               [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;;
            k) _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1
               [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;;
            "$(printf '\033')")
                IFS= read -r -n2 -t 0.1 _fseq 2>/dev/null || _fseq=""
                case "$_fseq" in
                    '[A') _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1
                          [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;;
                    '[B') _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total
                          [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;;
                    *) NEED_FULL_REDRAW=1; return ;;  # esc — close
                esac ;;
            "$(printf '\n')"|\
            "$(printf '\r')"|\
            l)
                _chosen=$(awk -v n="$_fsel" 'NR==n{print;exit}' "$_tmp")
                if [ -d "$_chosen" ]; then
                    PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
                elif [ -e "$_chosen" ]; then
                    PREV_CWD="$CWD"; CWD=$(dirname "$_chosen"); norm_cwd
                fi
                FILTER=""; SELECTED=""
                SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
                load_entries
                # try to highlight the found file
                _fname="${_chosen##*/}"
                [ -d "$_chosen" ] && _fname="${_fname}/"
                _fidx=$(find_entry "$_fname")
                [ "$_fidx" -ge 0 ] 2>/dev/null && SEL=$_fidx
                return ;;
            q|Q|h) NEED_FULL_REDRAW=1; return ;;
        esac
    done
}

do_jump_path() {
    goto "$(term_rows)" 1
    printf '%s%s jump to: %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
    if read_line && [ -n "$READ_LINE" ]; then
        # expand ~ manually
        case "$READ_LINE" in
            "~"*)  READ_LINE="${HOME}${READ_LINE#\~}" ;;
        esac
        if [ -d "$READ_LINE" ]; then
            PREV_CWD="$CWD"
            CWD="$READ_LINE"; norm_cwd
            FILTER=""; SELECTED=""
            SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
            load_entries
        else
            INFO_MSG="not found: ${READ_LINE}"
            NEED_FULL_REDRAW=1; draw
        fi
    else
        NEED_FULL_REDRAW=1; draw
    fi
}

do_go_back() {
    [ "$CWD" = "/" ] && return
    FILTER=""
    SELECTED=""
    PREV_CWD="$CWD"
    LAST_CHILD="${CWD##*/}/"   # name of current dir with trailing slash
    CWD=$(cd "$(dirname "$CWD")" && pwd)
    SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
    load_entries
    # restore selection to the directory we just came from
    if [ -n "$LAST_CHILD" ]; then
        idx=$(find_entry "$LAST_CHILD")
        [ "$idx" -ge 0 ] 2>/dev/null && SEL=$idx
    fi
    LAST_CHILD=""
}

do_open() {
    entry=$(get_entry "$SEL")
    case "$entry" in
        */)
            FILTER=""
            SELECTED=""
            _target=$(joinpath "${entry%/}")
            if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then
                INFO_MSG="permission denied: ${entry%/}"
                NEED_FULL_REDRAW=1
                return
            fi
            PREV_CWD="$CWD"
            CWD=$(cd "$_target" && pwd); norm_cwd
            SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
            load_entries
            ;;
        *@)
            _name="${entry%@}"
            _target=$(joinpath "$_name")
            if [ -d "$_target" ]; then
                if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then
                    INFO_MSG="permission denied: ${_name}"
                    NEED_FULL_REDRAW=1
                    return
                fi
                FILTER=""; SELECTED=""
                PREV_CWD="$CWD"
                CWD=$(cd "$_target" && pwd); norm_cwd
                SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
                load_entries
            else
                if [ ! -r "$_target" ]; then
                    INFO_MSG="permission denied: ${_name}"
                    NEED_FULL_REDRAW=1
                    return
                fi
                restore_term; show_cursor
                printf '\033[2J\033[H'
                open_file "$_target"
                setup_term; hide_cursor
                NEED_FULL_REDRAW=1
            fi
            ;;
        *)
            _target="${CWD}/${entry}"
            if [ ! -r "$_target" ]; then
                INFO_MSG="permission denied: ${entry}"
                NEED_FULL_REDRAW=1
                return
            fi
            restore_term; show_cursor
            printf '\033[2J\033[H'
            open_file "$_target"
            setup_term; hide_cursor
            NEED_FULL_REDRAW=1
            ;;
    esac
}

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_chmod_x() {
    entry=$(get_entry "$SEL")
    [ -z "$entry" ] && return
    case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
    _target=$(joinpath "${entry%@}")
    if chmod +x "$_target" 2>/dev/null; then
        INFO_MSG="chmod +x: ${entry}"
    else
        INFO_MSG="chmod failed: ${entry}"
    fi
    NEED_FULL_REDRAW=1
}

do_chmod_nox() {
    entry=$(get_entry "$SEL")
    [ -z "$entry" ] && return
    case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
    _target=$(joinpath "${entry%@}")
    if chmod -x "$_target" 2>/dev/null; then
        INFO_MSG="chmod -x: ${entry}"
    else
        INFO_MSG="chmod failed: ${entry}"
    fi
    NEED_FULL_REDRAW=1
}

do_info() {
    entry=$(get_entry "$SEL")
    [ -z "$entry" ] && return
    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"; rm -f /tmp/_sfm_list /tmp/_sfm_find; 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 '\033[3~')") do_delete ;;
        "$(printf '\n')"|\
        "$(printf '\r')"|\
        "$(printf '\033[C')"|\
        l) do_open    ;;
        "$(printf '\033[D')"|\
        h) do_go_back ;;
        b)   do_bookmark_add  ;;
        B)   do_bookmark_jump ;;
        '?') do_help          ;;
        R)   load_entries; INFO_MSG="refreshed" ;;
        /)   do_search        ;;
        '.')  do_toggle_hidden   ;;
        T)    do_toggle_details  ;;
        P)    do_toggle_preview  ;;
        i)   do_info          ;;
        '+') do_chmod_x    ;;
        '-') do_chmod_nox ;;
        o)   do_open_with     ;;
        s)   do_sort          ;;
        u)   do_trash        ;;
        U)   do_open_trash   ;;
        f)   do_find        ;;
        ':') do_jump_path   ;;
        '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED=""
             SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;;
        c)   do_copy_path   ;;
        "$(printf '\033')") do_clear_filter ;;
        ' ') do_toggle_select ;;
        a)   do_select_all   ;;
        y)  do_yank    ;;
        x)  do_cut     ;;
        p)  do_paste   ;;
        d)  do_delete  ;;
        r)  do_rename  ;;
        m)  do_mkdir   ;;
        n)  do_newfile ;;
        q|Q) break ;;
    esac
done

restore_term
show_cursor
printf '\033[2J\033[H'
printf '%s\n' "$CWD"
