#!/bin/sh # sfm - Simple File Manager in POSIX sh # --- terminal control --- tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; } RESET=$(tput_cmd sgr0) BOLD=$(tput_cmd bold) REV=$(tput_cmd rev) RED=$(tput_cmd setaf 1) GREEN=$(tput_cmd setaf 2) YELLOW=$(tput_cmd setaf 3) CYAN=$(tput_cmd setaf 6) WHITE=$(tput_cmd setaf 7) MAGENTA=$(tput_cmd setaf 5) BLUE=$(tput_cmd setaf 4) ERASE_LINE=$(tput_cmd el || printf '\033[K') goto() { printf '\033[%d;%dH' "$1" "$2"; } hide_cursor() { printf '\033[?25l'; } show_cursor() { printf '\033[?25h'; } term_rows() { tput_cmd lines || echo 24; } term_cols() { tput_cmd cols || echo 80; } # --- state --- if [ -n "$1" ]; then CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; } else CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/" fi SEL=0 OFFSET=0 PREV_SEL=0 PREV_OFFSET=0 NEED_FULL_REDRAW=1 LAST_CHILD="" # name of dir we came from, used to restore selection on go-back FILTER="" # current search string; empty = no filter ALL_ENTRIES="" # unfiltered entries ALL_COUNT=0 SEARCHING=0 # 1 while in search input mode SELECTED="" # newline-separated list of multi-selected entry names CLIPBOARD="" # full path of yanked/cut entry CLIP_MODE="" # "copy" or "cut" INFO_MSG="" # ephemeral message shown in botbar SHOW_HIDDEN=0 # 1 = show dotfiles SORT_MODE="name" # name | size | date SHOW_DETAILS=0 # 1 = show size+date column SHOW_PREVIEW=0 # 1 = show preview pane on right TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash" mkdir -p "$TRASH_DIR" PREV_CWD="" # last visited directory, for jumping back # normalise CWD: resolve to absolute canonical path, no double slashes norm_cwd() { CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/" # squeeze any double slashes (some systems return // for //) CWD=$(printf '%s' "$CWD" | tr -s '/') } # safely join CWD with a name, avoiding double slash at root joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; } BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks" mkdir -p "$(dirname "$BOOKMARK_FILE")" [ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE" # --- load directory --- load_entries() { ENTRIES="" COUNT=0 # hidden dirs first if [ "$SHOW_HIDDEN" = "1" ]; then for d in "$CWD"/.*/; do [ -d "$d" ] || continue [ -L "${d%/}" ] && continue name="${d%/}"; name="${name##*/}" [ "$name" = "." ] || [ "$name" = ".." ] && continue [ "$name" = ".*" ] && continue ENTRIES="$ENTRIES ${name}/" COUNT=$((COUNT + 1)) done fi # regular dirs for d in "$CWD"/*/; do [ -d "$d" ] || continue [ -L "${d%/}" ] && continue name="${d%/}"; name="${name##*/}" [ "$name" = "*" ] && continue case "$name" in .*) continue ;; esac ENTRIES="$ENTRIES ${name}/" COUNT=$((COUNT + 1)) done # hidden files first if [ "$SHOW_HIDDEN" = "1" ]; then for f in "$CWD"/.*; do [ -e "$f" ] || [ -L "$f" ] || continue [ -d "$f" ] && ! [ -L "$f" ] && continue name="${f##*/}" [ "$name" = ".*" ] && continue if [ -L "$f" ]; then ENTRIES="$ENTRIES ${name}@" else ENTRIES="$ENTRIES ${name}" fi COUNT=$((COUNT + 1)) done fi # regular files for f in "$CWD"/*; do [ -e "$f" ] || [ -L "$f" ] || continue [ -d "$f" ] && ! [ -L "$f" ] && continue name="${f##*/}" [ "$name" = "*" ] && continue case "$name" in .*) continue ;; esac if [ -L "$f" ]; then ENTRIES="$ENTRIES ${name}@" else ENTRIES="$ENTRIES ${name}" fi COUNT=$((COUNT + 1)) done ENTRIES="${ENTRIES# }" # --- sort files only (dirs always stay first) --- if [ "$SORT_MODE" != "name" ]; then # separate dirs and files _dirs=""; _files="" _rest="$ENTRIES" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue case "$_line" in */) if [ -z "$_dirs" ]; then _dirs="$_line" else _dirs="$_dirs $_line"; fi ;; *) if [ -z "$_files" ]; then _files="$_line" else _files="$_files $_line"; fi ;; esac done # sort files using ls into a tmp file if [ -n "$_files" ]; then case "$SORT_MODE" in size) ls -1Sp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted_$$ ;; date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted_$$ ;; esac # only keep files that were already in our list (respects hidden filter) _sorted="" while IFS= read -r _n; do [ -z "$_n" ] && continue # check if _n is in _files case " ${_files} " in *" ${_n} "*) if [ -z "$_sorted" ]; then _sorted="$_n" else _sorted="$_sorted $_n"; fi ;; esac done < /tmp/_fm_sorted_$$ _files="$_sorted" fi # reassemble: dirs + sorted files ENTRIES="" _rest="$_dirs" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue if [ -z "$ENTRIES" ]; then ENTRIES="$_line" else ENTRIES="$ENTRIES $_line"; fi done _rest="$_files" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue if [ -z "$ENTRIES" ]; then ENTRIES="$_line" else ENTRIES="$ENTRIES $_line"; fi done # recount COUNT=0 _rest="$ENTRIES" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -n "$_line" ] && COUNT=$((COUNT + 1)) done fi ALL_ENTRIES="$ENTRIES" ALL_COUNT="$COUNT" apply_filter NEED_FULL_REDRAW=1 } # filter ALL_ENTRIES by FILTER string, update ENTRIES/COUNT apply_filter() { if [ -z "$FILTER" ]; then ENTRIES="$ALL_ENTRIES" COUNT="$ALL_COUNT" else ENTRIES="" COUNT=0 _rest="$ALL_ENTRIES" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue case "$_line" in *"$FILTER"*) if [ -z "$ENTRIES" ]; then ENTRIES="$_line" else ENTRIES="$ENTRIES $_line"; fi COUNT=$((COUNT + 1)) ;; esac done fi # write to tmp file for O(1) line access in get_entry printf '%s\n' "$ENTRIES" > /tmp/_sfm_list_$$ } get_entry() { sed -n "$(($1 + 1))p" /tmp/_sfm_list_$$ } # find index of entry by name (0-based), returns -1 if not found find_entry() { _fe_n=$(grep -nFx "$1" /tmp/_sfm_list_$$ 2>/dev/null | head -1 | cut -d: -f1) if [ -n "$_fe_n" ]; then printf '%d' $((_fe_n - 1)) else printf '%d' -1 fi } # is entry name in SELECTED list? is_selected() { printf '%s\n' "$SELECTED" | grep -Fxq "$1" } count_selected() { _cnt=0 _rest="$SELECTED" while [ -n "$_rest" ]; do _line="${_rest%% *}" _rest="${_rest#* }" [ "$_rest" = "$_line" ] && _rest="" # no more newlines [ -n "$_line" ] && _cnt=$((_cnt + 1)) done printf '%d' "$_cnt" } toggle_selected() { if is_selected "$1"; then _new="" _rest="$SELECTED" while [ -n "$_rest" ]; do _line="${_rest%% *}" _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ "$_line" = "$1" ] && continue [ -z "$_line" ] && continue if [ -z "$_new" ]; then _new="$_line" else _new="$_new $_line" fi done SELECTED="$_new" else if [ -z "$SELECTED" ]; then SELECTED="$1" else SELECTED="$SELECTED $1" fi fi } # render one entry row in-place (no newline, no cursor movement after) # args: row idx is_selected cols render_row() { _row=$1; _idx=$2; _selected=$3; _cols=$4 entry=$(get_entry "$_idx") case "$entry" in */) colour="${BLUE}${BOLD}" ;; *@) _name="${entry%@}" if [ -e "${CWD}/${_name}" ]; then colour="${CYAN}${BOLD}" else colour="${RED}${BOLD}" fi ;; *) _fc="${CWD}/${entry}" if [ -x "$_fc" ]; then colour="${GREEN}${BOLD}" elif [ -c "$_fc" ]; then colour="${MAGENTA}${BOLD}" elif [ -b "$_fc" ]; then colour="${MAGENTA}${BOLD}" elif [ ! -r "$_fc" ] || [ ! -w "$_fc" ]; then colour="${RED}${BOLD}" else colour="${WHITE}" fi ;; esac # multi-select marker if is_selected "$entry"; then _marker="${YELLOW}*${colour}" else _marker=" " fi # build display string case "$entry" in *@) _name="${entry%@}" _target=$(readlink "${CWD}/${_name}" 2>/dev/null || echo "?") # mark broken symlinks clearly if [ ! -e "${CWD}/${_name}" ]; then display="${_name} -> ${_target} [broken]" else display="${_name} -> ${_target}" fi ;; *) display="${entry}" ;; esac # build detail string (size + date) if enabled — fixed width for alignment _detail="" if [ "$SHOW_DETAILS" = "1" ]; then _path="${CWD}/${entry%/}"; _path="${_path%@}" _info=$(ls -ldh "$_path" 2>/dev/null) # parse ls output with parameter expansion — no awk fork set -- $_info _sz=$5; _dt="$6 $7" _detail=$(printf ' %6s %-6s' "$_sz" "$_dt") set -- fi # layout: marker(1) + name + spaces + detail _dlen=${#_detail} maxw=$((_cols - 2 - _dlen)) if [ "${#display}" -gt "$maxw" ]; then _trunc=$((maxw - 3)) while [ "${#display}" -gt "$_trunc" ]; do display="${display%?}" done display="${display}..." fi _namew=$((${#display} + 1)) # 1 for marker padlen=$((_cols - _namew - _dlen)) [ "$padlen" -lt 0 ] && padlen=0 spaces=$(printf '%*s' "$padlen" '') goto "$_row" 1 if [ "$_selected" = "1" ]; then printf '%s%s%s%s%s%s%s' \ "${REV}${colour}" "${_marker}" "${display}" \ "${spaces}" \ "${colour}${_detail}" \ "${RESET}" "" else printf '%s%s%s%s%s%s%s' \ "${colour}" "${_marker}" "${display}" \ "${spaces}" \ "${colour}${_detail}" \ "${RESET}" "" fi } draw_preview() { _rows=$1; _cols=$2; _px=$3; _pw=$4 entry=$(get_entry "$SEL") # clear preview area first _r=2 while [ "$_r" -le $((_rows - 1)) ]; do goto "$_r" "$_px" printf '\033[K' _r=$((_r + 1)) done [ -z "$entry" ] && return _path=$(joinpath "${entry%/}"); _path="${_path%@}" # draw vertical divider _r=2 while [ "$_r" -le $((_rows - 1)) ]; do goto "$_r" $((_px - 1)) printf '%s|%s' "${CYAN}" "${RESET}" _r=$((_r + 1)) done case "$entry" in */) # directory: list contents _max_lines=$((_rows - 3)) _pr=2 for _de in "$_path"/*/; do [ "$_pr" -gt $((_rows - 1)) ] && break [ -d "$_de" ] || continue _dn="${_de%/}"; _dn="${_dn##*/}" [ "$_dn" = "*" ] && continue _dl=" ${_dn}/" [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~" goto "$_pr" "$_px" printf '%s%s%s' "${BLUE}${BOLD}" "$_dl" "${RESET}" _pr=$((_pr + 1)) done for _fe in "$_path"/*; do [ "$_pr" -gt $((_rows - 1)) ] && break [ -e "$_fe" ] || continue [ -d "$_fe" ] && continue _fn="${_fe##*/}" [ "$_fn" = "*" ] && continue _fl=" ${_fn}" [ "${#_fl}" -gt "$((_pw - 1))" ] && _fl="$(printf '%s' "$_fl" | cut -c1-$((_pw-2)))~" goto "$_pr" "$_px" printf '%s%s%s' "${WHITE}" "$_fl" "${RESET}" _pr=$((_pr + 1)) done if [ "$_pr" -eq 2 ]; then goto 2 "$_px" printf '%s(empty)%s' "${WHITE}" "${RESET}" fi ;; *@) # symlink _tgt=$(readlink "$_path" 2>/dev/null || echo "?") goto 2 "$_px" printf '%s[symlink]%s' "${MAGENTA}${BOLD}" "${RESET}" goto 3 "$_px" printf '%s-> %s%s' "${WHITE}" "${_tgt}" "${RESET}" ;; *) # detect if text via extension or mime _ext="${entry##*.}" _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]') _is_text=0 case "$_ext" in txt|md|markdown|rst|log|conf|cfg|ini|toml|yaml|yml|\ sh|bash|zsh|py|rb|pl|lua|js|ts|json|xml|html|htm|\ css|c|h|cpp|rs|go|java|php|r|sql|vim|env|gitignore|\ diff|patch|csv|tsv|lock|mod|sum) _is_text=1 ;; esac if [ "$_is_text" = "0" ] && command -v file >/dev/null 2>&1; then case "$(file --mime-type -b "$_path" 2>/dev/null)" in text/*) _is_text=1 ;; esac fi if [ "$_is_text" = "1" ]; then _max_lines=$((_rows - 3)) _line_n=0 while IFS= read -r _line && [ "$_line_n" -lt "$_max_lines" ]; do # truncate to preview width if [ "${#_line}" -gt "$((_pw - 1))" ]; then _line=$(printf '%s' "$_line" | cut -c1-$((_pw - 2))) _line="${_line}~" fi goto "$((_line_n + 2))" "$_px" printf '%s%s%s' "${WHITE}" "$_line" "${RESET}" _line_n=$((_line_n + 1)) done < "$_path" if [ "$_line_n" -eq 0 ]; then goto 2 "$_px" printf '%s(empty file)%s' "${WHITE}" "${RESET}" fi else # binary / unknown goto 2 "$_px" _sz=$(ls -lh "$_path" 2>/dev/null | awk '{print $5}') printf '%s[binary] %s%s' "${YELLOW}" "${_sz}" "${RESET}" fi ;; esac } draw_topbar() { _cols=$1 spaces=$(printf '%*s' "$_cols" '') goto 1 1 printf '%s%s%s' "${CYAN}${BOLD}" "${spaces}" "${RESET}" } draw_botbar() { _rows=$1; _cols=$2 if [ "$SEARCHING" = "1" ]; then info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}" keys=" search: ${FILTER} " maxk=$((_cols - ${#info} - 1)) [ "${#keys}" -gt "$maxk" ] && keys=$(printf '%s' "$keys" | cut -c1-"$maxk") padlen=$((_cols - ${#info} - ${#keys})) [ "$padlen" -lt 0 ] && padlen=0 pad=$(printf '%*s' "$padlen" '') goto "$_rows" 1 printf '%s%s%s%s%s%s' \ "${BOLD}${CYAN}" "${info}" \ "${RESET}${pad}" \ "${YELLOW}${keys}" \ "${RESET}" elif [ -n "$INFO_MSG" ]; then info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}" msg=" ${INFO_MSG} " padlen=$((_cols - ${#info} - ${#msg})) [ "$padlen" -lt 0 ] && padlen=0 pad=$(printf '%*s' "$padlen" '') goto "$_rows" 1 printf '%s%s%s%s%s%s' \ "${BOLD}${CYAN}" "${info}" \ "${RESET}${pad}" \ "${YELLOW}${msg}" \ "${RESET}" INFO_MSG="" else _ind="" [ -n "$CLIPBOARD" ] && _ind=" [${CLIP_MODE}]" _sc=$(count_selected) [ "$_sc" -gt 0 ] && _ind="${_ind} [sel:${_sc}]" [ "$SHOW_HIDDEN" = "1" ] && _ind="${_ind} [hidden]" [ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]" [ "$SHOW_DETAILS" = "1" ] && _ind="${_ind} [details]" hint=" press ? for help " info=" $([ "$COUNT" -eq 0 ] && echo 0 || echo $((SEL + 1)))/${COUNT} ${CWD}${_ind}" padlen=$((_cols - ${#info} - ${#hint})) [ "$padlen" -lt 0 ] && padlen=0 pad=$(printf '%*s' "$padlen" '') goto "$_rows" 1 printf '%s%s%s%s%s%s' \ "${BOLD}${CYAN}" "${info}" \ "${RESET}${pad}" \ "${YELLOW}${hint}" \ "${RESET}" fi } draw() { ROWS=$(term_rows) COLS=$(term_cols) LIST_ROWS=$((ROWS - 2)) # row 1 = topbar, row ROWS = botbar # split layout when preview enabled if [ "$SHOW_PREVIEW" = "1" ]; then LIST_COLS=$((COLS / 2)) PREV_COL=$((LIST_COLS + 2)) PREV_WIDTH=$((COLS - LIST_COLS - 2)) else LIST_COLS=$COLS fi # clamp selection [ "$SEL" -lt 0 ] && SEL=0 [ "$COUNT" -gt 0 ] && [ "$SEL" -ge "$COUNT" ] && SEL=$((COUNT - 1)) # update scroll offset if [ "$SEL" -lt "$OFFSET" ]; then OFFSET=$SEL elif [ "$SEL" -ge $((OFFSET + LIST_ROWS)) ]; then OFFSET=$((SEL - LIST_ROWS + 1)) fi # viewport shifted → must redraw all rows [ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1 if [ "$NEED_FULL_REDRAW" = "1" ]; then printf '\033[H' draw_topbar "$COLS" row=2 idx=$OFFSET while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do sel=0; [ "$idx" -eq "$SEL" ] && sel=1 render_row "$row" "$idx" "$sel" "$LIST_COLS" row=$((row + 1)) idx=$((idx + 1)) done # show (empty) if directory has no entries if [ "$COUNT" -eq 0 ]; then goto 2 1 printf '%s (empty)%s%s' "${WHITE}" "${ERASE_LINE}" "${RESET}" row=3 fi # blank leftover rows while [ "$row" -le $((LIST_ROWS + 1)) ]; do goto "$row" 1 printf '%s' "${ERASE_LINE}" row=$((row + 1)) done [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH" draw_botbar "$ROWS" "$COLS" NEED_FULL_REDRAW=0 else # --- fast path --- if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then return 2>/dev/null || true fi prev_row=$(( (PREV_SEL - OFFSET) + 2 )) new_row=$(( (SEL - OFFSET) + 2 )) if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then render_row "$prev_row" "$PREV_SEL" 0 "$LIST_COLS" fi if [ "$new_row" -ge 2 ] && [ "$new_row" -le $((LIST_ROWS + 1)) ]; then render_row "$new_row" "$SEL" 1 "$LIST_COLS" fi [ "$SHOW_PREVIEW" = "1" ] && draw_preview "$ROWS" "$COLS" "$PREV_COL" "$PREV_WIDTH" draw_botbar "$ROWS" "$COLS" fi PREV_SEL=$SEL PREV_OFFSET=$OFFSET } # --- input --- read_key() { IFS= read -r -n1 key 2>/dev/null || IFS= read -r key if [ "$key" = "$(printf '\033')" ]; then IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2="" if [ -z "$_k2" ]; then printf '\033'; return fi IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3="" # if sequence ends with ~, it may have more chars — read one more case "${_k2}${_k3}" in '['[0-9]) IFS= read -r -n1 -t 0.1 _k4 2>/dev/null || _k4="" key="${key}${_k2}${_k3}${_k4}" ;; *) key="${key}${_k2}${_k3}" ;; esac fi printf '%s' "$key" } setup_term() { old_stty=$(stty -g); stty -echo -icanon min 1 time 0; } restore_term() { stty "$old_stty"; } # --- actions --- # --- smart file opener --- # Detects file type by extension then mime, picks the right program. # Terminal programs run in the foreground; GUI programs are backgrounded. _run_tty() { "$@"; } _run_gui() { "$@" >/dev/null 2>&1 & } open_file() { _f="$1" # use user opener script if it exists and is executable _opener="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/opener" if [ -x "$_opener" ]; then "$_opener" "$_f" return fi _ext="${_f##*.}" _ext=$(printf '%s' "$_ext" | tr '[:upper:]' '[:lower:]') # --- by extension --- case "$_ext" in # text / code → editor txt|md|markdown|rst|csv|tsv|log|conf|cfg|ini|toml|yaml|yml|\ sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|json|xml|html|\ htm|css|scss|sass|c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|\ cs|php|r|sql|vim|diff|patch|makefile|dockerfile|gitignore|\ env|lock|mod|sum) _run_tty ${EDITOR:-vi} "$_f" return ;; # images → GUI viewer jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif) for _v in imv imvr feh sxiv nsxiv eog eom viewnior shotwell gimp; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; # video → GUI player mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv) for _v in mpv vlc mplayer totem celluloid haruna; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; # audio → player (mpv/vlc run fine headless too) mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff) for _v in mpv vlc mplayer cmus mocp; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; # PDF / documents pdf) for _v in zathura evince okular mupdf atril xreader; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; # office docs odt|ods|odp|doc|docx|xls|xlsx|ppt|pptx) for _v in libreoffice soffice; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; # archives → list contents in pager zip|tar|gz|bz2|xz|zst|7z|rar) if command -v atool >/dev/null 2>&1; then atool -l "$_f" 2>&1 | ${PAGER:-less}; return elif command -v bsdtar >/dev/null 2>&1; then bsdtar -tf "$_f" 2>&1 | ${PAGER:-less}; return fi ;; esac # --- fallback: try mime type via `file` --- if command -v file >/dev/null 2>&1; then _mime=$(file --mime-type -b "$_f" 2>/dev/null) case "$_mime" in text/*|application/json|application/xml|application/javascript) _run_tty ${EDITOR:-vi} "$_f"; return ;; image/*) for _v in imv feh sxiv nsxiv eog gimp; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; video/*) for _v in mpv vlc mplayer; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; audio/*) for _v in mpv vlc mplayer; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; application/pdf) for _v in zathura evince okular mupdf; do command -v "$_v" >/dev/null 2>&1 && { _run_gui "$_v" "$_f"; return; } done ;; esac fi # --- last resort: xdg-open / open, else editor --- if command -v xdg-open >/dev/null 2>&1; then _run_gui xdg-open "$_f" elif command -v open >/dev/null 2>&1; then _run_gui open "$_f" else _run_tty ${EDITOR:-vi} "$_f" fi } # read a line of input in raw mode, returns result in READ_LINE # returns 1 if user pressed Esc (cancel), 0 on Enter read_line() { READ_LINE="${_init:-}" _init="" show_cursor printf '%s' "$READ_LINE" while true; do IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch case "$_ch" in "$(printf '\033')") # read with timeout to distinguish bare esc from sequences IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" hide_cursor READ_LINE=""; return 1 ;; "$(printf '\n')"|\ "$(printf '\r')") hide_cursor return 0 ;; "$(printf '\177')"|\ "$(printf '\010')") if [ -n "$READ_LINE" ]; then READ_LINE="${READ_LINE%?}" printf '\b \b' fi ;; *) READ_LINE="${READ_LINE}${_ch}" printf '%s' "$_ch" ;; esac done } do_open_with() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return case "$entry" in */) INFO_MSG="cannot open-with a directory"; NEED_FULL_REDRAW=1; return ;; esac _target="${CWD}/${entry%@}" goto "$(term_rows)" 1 printf '%s%s Open "%s" with (esc=cancel): %s' \ "${ERASE_LINE}" "${CYAN}${BOLD}" "$entry" "${RESET}" if read_line && [ -n "$READ_LINE" ]; then # check the program exists if ! command -v "$READ_LINE" >/dev/null 2>&1; then INFO_MSG="not found: ${READ_LINE}" NEED_FULL_REDRAW=1 return fi restore_term; show_cursor printf '\033[2J\033[H' "$READ_LINE" "$_target" setup_term; hide_cursor NEED_FULL_REDRAW=1 else NEED_FULL_REDRAW=1; draw fi } do_newfile() { goto "$(term_rows)" 1 printf '%s%s File name: %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}" if read_line && [ -n "$READ_LINE" ]; then touch "${CWD}/${READ_LINE}" load_entries else NEED_FULL_REDRAW=1; draw fi } # --- help overlay helpers (must be top-level for dash compatibility) --- _help_bx=0; _help_by=0; _help_iw=0; _help_hl="" help_row() { _r=$1; _text=$2 goto "$(( _help_by + _r ))" "$_help_bx" if [ "${#_text}" -gt "$_help_iw" ]; then _text=$(printf '%s' "$_text" | cut -c1-"$_help_iw") fi _pl=$(( _help_iw - ${#_text} )) _p=$(printf '%*s' "$_pl" '') printf '%s|%s%s%s%s|%s' \ "${BOLD}${CYAN}" "${RESET}" "${_text}" "${_p}" \ "${BOLD}${CYAN}" "${RESET}" } help_sep() { goto "$(( _help_by + $1 ))" "$_help_bx" printf '%s|%s|%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" } do_help() { ROWS=$(term_rows) COLS=$(term_cols) bw=$((COLS - 4)) [ "$bw" -gt 52 ] && bw=52 [ "$bw" -lt 36 ] && bw=36 bh=46 _help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1 _help_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1 _help_iw=$((bw - 2)) _help_hl=$(printf '%*s' "$_help_iw" '' | tr ' ' '-') goto "$_help_by" "$_help_bx" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" help_row 1 "" help_row 2 " KEYBOARD SHORTCUTS" help_sep 3 help_row 4 " j/k up/down g/G top/bottom" help_row 5 " h/left go back" help_row 6 " l/right/enter open / enter dir" help_sep 7 help_row 8 " / search filter" help_row 9 " esc clear filter" help_row 10 " . toggle hidden files" help_row 11 " i file info in status bar" help_row 12 " s cycle sort: name/size/date" help_row 13 " T toggle size/date details" help_row 14 " P toggle preview pane" help_sep 15 help_row 16 " space toggle multi-select" help_row 17 " a select all / deselect all" help_row 18 " y yank/copy (works on selection)" help_row 19 " x cut (works on selection)" help_row 20 " p paste" help_row 21 " d delete (works on selection)" help_sep 22 help_row 23 " r rename R refresh" help_row 24 " m make directory" help_row 25 " n new file" help_row 26 " u trash file (safe delete)" help_row 27 " U open trash directory" help_row 28 " ! drop to shell in CWD" help_row 29 " o open with custom program" help_row 30 " + chmod +x (make executable)" help_row 31 " - chmod -x (remove executable)" help_sep 32 help_row 33 " b bookmark current dir" help_row 34 " B open bookmark picker" help_row 35 " c copy path to clipboard" help_row 36 " ~ go to home directory" help_row 37 " \` jump to previous directory" help_row 38 " : jump to path" help_row 39 " f find files recursively" help_sep 40 help_row 41 " q quit" help_row 42 " ? this help" help_row 43 "" help_row 44 " press any key to close..." goto "$(( _help_by + bh ))" "$_help_bx" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" read_key > /dev/null NEED_FULL_REDRAW=1 } do_search() { FILTER="" SEARCHING=1 show_cursor while true; do apply_filter SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 NEED_FULL_REDRAW=1 draw IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch case "$_ch" in "$(printf '\033')") # read with short timeout to catch escape sequences IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" if [ -z "$_seq" ]; then # bare esc — clear filter and exit search FILTER=""; SEARCHING=0 apply_filter SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 hide_cursor; NEED_FULL_REDRAW=1; draw; return fi # else it was an arrow key or other sequence — ignore it ;; "$(printf '\n')"|\ "$(printf '\r')") SEARCHING=0; hide_cursor; NEED_FULL_REDRAW=1; return ;; "$(printf '\177')"|\ "$(printf '\010')") [ -n "$FILTER" ] && FILTER="${FILTER%?}" ;; *) FILTER="${FILTER}${_ch}" ;; esac done } do_clear_filter() { FILTER="" SEARCHING=0 apply_filter SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 NEED_FULL_REDRAW=1 } do_toggle_hidden() { if [ "$SHOW_HIDDEN" = "0" ]; then SHOW_HIDDEN=1 INFO_MSG="hidden files shown" else SHOW_HIDDEN=0 INFO_MSG="hidden files hidden" fi SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries } do_toggle_details() { if [ "$SHOW_DETAILS" = "0" ]; then SHOW_DETAILS=1; INFO_MSG="details on" else SHOW_DETAILS=0; INFO_MSG="details off" fi NEED_FULL_REDRAW=1 } do_toggle_preview() { if [ "$SHOW_PREVIEW" = "0" ]; then SHOW_PREVIEW=1; INFO_MSG="preview on" else SHOW_PREVIEW=0; INFO_MSG="preview off" fi NEED_FULL_REDRAW=1 } do_find() { goto "$(term_rows)" 1 printf '%s%s find (recursive): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}" if ! read_line || [ -z "$READ_LINE" ]; then NEED_FULL_REDRAW=1; draw; return fi _query="$READ_LINE" # run find and collect results into a tmp file _tmp=/tmp/_sfm_find_$$ find "$CWD" -name "*${_query}*" 2>/dev/null | sort > "$_tmp" _total=$(wc -l < "$_tmp" | tr -d '[:space:]') if [ "$_total" -eq 0 ]; then INFO_MSG="no results for: ${_query}" NEED_FULL_REDRAW=1; return fi # show results in an interactive picker ROWS=$(term_rows); COLS=$(term_cols) _pw=$(( COLS - 4 )); [ "$_pw" -gt 80 ] && _pw=80; [ "$_pw" -lt 30 ] && _pw=30 _ph=$(( ROWS - 4 )); [ "$_ph" -lt 5 ] && _ph=5 _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1 _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1 _iw=$((_pw - 2)) _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') _vis=$((_ph - 3)) # visible result rows _fsel=1 _foff=1 # scroll offset (1-based) while true; do # draw box goto "$_py" "$_px" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" # title goto "$((_py+1))" "$_px" _title=" find: ${_query} (${_total} results)" [ "${#_title}" -gt "$_iw" ] && _title=$(printf '%s' "$_title" | cut -c1-"$_iw") _tpl=$((_iw - ${#_title})); _tp=$(printf '%*s' "$_tpl" '') printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_title" "$_tp" "${BOLD}${CYAN}" "${RESET}" goto "$((_py+2))" "$_px" printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" # results _ri=0 while [ "$_ri" -lt "$_vis" ]; do _ridx=$((_foff + _ri)) goto "$((_py+3+_ri))" "$_px" if [ "$_ridx" -le "$_total" ]; then _rline=$(awk -v n="$_ridx" 'NR==n{print;exit}' "$_tmp") # strip CWD prefix for display _rdisplay="${_rline#$CWD/}" [ "${#_rdisplay}" -gt "$_iw" ] && \ _rdisplay="...$(printf '%s' "$_rdisplay" | rev | cut -c1-$((_iw-4)) | rev)" _rpl=$((_iw - ${#_rdisplay})); _rp=$(printf '%*s' "$_rpl" '') if [ "$_ridx" -eq "$_fsel" ]; then printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_rdisplay" "$_rp" "${RESET}${BOLD}${CYAN}" "${RESET}" else printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_rdisplay" "$_rp" "${BOLD}${CYAN}" "${RESET}" fi else _ep=$(printf '%*s' "$_iw" '') printf '%s|%s|%s' "${BOLD}${CYAN}" "$_ep" "${RESET}" fi _ri=$((_ri+1)) done goto "$((_py+_ph))" "$_px" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" # read key IFS= read -r -n1 _fk 2>/dev/null || IFS= read -r _fk case "$_fk" in j) _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;; k) _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1 [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;; "$(printf '\033')") IFS= read -r -n2 -t 0.1 _fseq 2>/dev/null || _fseq="" case "$_fseq" in '[A') _fsel=$((_fsel-1)); [ "$_fsel" -lt 1 ] && _fsel=1 [ "$_fsel" -lt "$_foff" ] && _foff=$((_foff-1)); [ "$_foff" -lt 1 ] && _foff=1 ;; '[B') _fsel=$((_fsel+1)); [ "$_fsel" -gt "$_total" ] && _fsel=$_total [ "$_fsel" -ge $((_foff+_vis)) ] && _foff=$((_foff+1)) ;; *) NEED_FULL_REDRAW=1; return ;; # esc — close esac ;; "$(printf '\n')"|\ "$(printf '\r')"|\ l) _chosen=$(awk -v n="$_fsel" 'NR==n{print;exit}' "$_tmp") if [ -d "$_chosen" ]; then PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd elif [ -e "$_chosen" ]; then PREV_CWD="$CWD"; CWD=$(dirname "$_chosen"); norm_cwd fi FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries # try to highlight the found file _fname="${_chosen##*/}" [ -d "$_chosen" ] && _fname="${_fname}/" _fidx=$(find_entry "$_fname") [ "$_fidx" -ge 0 ] 2>/dev/null && SEL=$_fidx return ;; q|Q|h) NEED_FULL_REDRAW=1; return ;; esac done } do_jump_path() { goto "$(term_rows)" 1 printf '%s%s jump to: %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}" if read_line && [ -n "$READ_LINE" ]; then # expand ~ manually case "$READ_LINE" in "~"*) READ_LINE="${HOME}${READ_LINE#\~}" ;; esac if [ -d "$READ_LINE" ]; then PREV_CWD="$CWD" CWD="$READ_LINE"; norm_cwd FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries else INFO_MSG="not found: ${READ_LINE}" NEED_FULL_REDRAW=1; draw fi else NEED_FULL_REDRAW=1; draw fi } do_go_back() { [ "$CWD" = "/" ] && return FILTER="" SELECTED="" PREV_CWD="$CWD" LAST_CHILD="${CWD##*/}/" # name of current dir with trailing slash CWD=$(cd "$(dirname "$CWD")" && pwd) SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries # restore selection to the directory we just came from if [ -n "$LAST_CHILD" ]; then idx=$(find_entry "$LAST_CHILD") [ "$idx" -ge 0 ] 2>/dev/null && SEL=$idx fi LAST_CHILD="" } do_open() { entry=$(get_entry "$SEL") case "$entry" in */) FILTER="" SELECTED="" _target=$(joinpath "${entry%/}") if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then INFO_MSG="permission denied: ${entry%/}" NEED_FULL_REDRAW=1 return fi PREV_CWD="$CWD" CWD=$(cd "$_target" && pwd); norm_cwd SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries ;; *@) _name="${entry%@}" _target=$(joinpath "$_name") if [ -d "$_target" ]; then if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then INFO_MSG="permission denied: ${_name}" NEED_FULL_REDRAW=1 return fi FILTER=""; SELECTED="" PREV_CWD="$CWD" CWD=$(cd "$_target" && pwd); norm_cwd SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries else if [ ! -r "$_target" ]; then INFO_MSG="permission denied: ${_name}" NEED_FULL_REDRAW=1 return fi restore_term; show_cursor printf '\033[2J\033[H' open_file "$_target" setup_term; hide_cursor NEED_FULL_REDRAW=1 fi ;; *) _target="${CWD}/${entry}" if [ ! -r "$_target" ]; then INFO_MSG="permission denied: ${entry}" NEED_FULL_REDRAW=1 return fi restore_term; show_cursor printf '\033[2J\033[H' open_file "$_target" setup_term; hide_cursor NEED_FULL_REDRAW=1 ;; esac } # --- confirm overlay (yes/no popup) --- confirm_overlay() { _co_rows=$(term_rows); _co_cols=$(term_cols) _co_yes=" Yes " _co_no=" No " _co_gap=" " _co_content="${_co_yes}${_co_gap}${_co_no}" # size box to fit prompt or buttons, whichever is wider _co_w=$(( ${#_prompt} + 4 )) [ "$_co_w" -lt $(( ${#_co_content} + 4 )) ] && _co_w=$(( ${#_co_content} + 4 )) [ "$_co_w" -gt "$_co_cols" ] && _co_w=$((_co_cols - 2)) _co_iw=$((_co_w - 2)) _co_hl=$(printf '%*s' "$_co_iw" '' | tr ' ' '-') _co_x=$(( (_co_cols - _co_w) / 2 )); [ "$_co_x" -lt 0 ] && _co_x=0 _co_y=$(( (_co_rows - 5) / 2 )); [ "$_co_y" -lt 1 ] && _co_y=1 _co_sel=2 # 1=Yes, 2=No (default No for safety) while true; do # draw box top goto "$_co_y" "$_co_x" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}" # prompt line goto "$((_co_y+1))" "$_co_x" _co_pl=$((_co_iw - ${#_prompt})) [ "$_co_pl" -lt 0 ] && _co_pl=0 _co_pad=$(printf '%*s' "$_co_pl" '') printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_prompt" "$_co_pad" "${BOLD}${CYAN}" "${RESET}" # separator goto "$((_co_y+2))" "$_co_x" printf '%s|%s|%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}" # yes/no buttons goto "$((_co_y+3))" "$_co_x" _co_cpad=$(( (_co_iw - ${#_co_content}) / 2 )) [ "$_co_cpad" -lt 0 ] && _co_cpad=0 _co_left=$(printf '%*s' "$_co_cpad" '') _co_cpad=$(( _co_iw - ${#_co_content} - _co_cpad )) [ "$_co_cpad" -lt 0 ] && _co_cpad=0 _co_right=$(printf '%*s' "$_co_cpad" '') printf '%s|%s%s' "${BOLD}${CYAN}" "${RESET}" "$_co_left" if [ "$_co_sel" -eq 1 ]; then printf '%s%s%s' "${REV}${GREEN}${BOLD}" "$_co_yes" "${RESET}" else printf '%s' "$_co_yes" fi printf '%s' "$_co_gap" if [ "$_co_sel" -eq 2 ]; then printf '%s%s%s' "${REV}${RED}${BOLD}" "$_co_no" "${RESET}" else printf '%s' "$_co_no" fi printf '%s%s|%s' "$_co_right" "${BOLD}${CYAN}" "${RESET}" # box bottom goto "$((_co_y+4))" "$_co_x" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_co_hl" "${RESET}" # read key IFS= read -r -n1 _co_ch 2>/dev/null || IFS= read -r _co_ch case "$_co_ch" in "$(printf '\033')") IFS= read -r -n2 -t 0.1 _co_seq 2>/dev/null || _co_seq="" case "$_co_seq" in '[D'|'[C') # left/right arrow if [ "$_co_sel" -eq 1 ]; then _co_sel=2; else _co_sel=1; fi ;; *) return 1 ;; # bare esc or unrecognised sequence esac ;; h) _co_sel=1 ;; l) _co_sel=2 ;; j) _co_sel=2 ;; k) _co_sel=1 ;; y|Y) return 0 ;; n|N) return 1 ;; "$(printf '\n')"|"$(printf '\r')") if [ "$_co_sel" -eq 1 ]; then return 0; else return 1; fi ;; esac done } do_delete() { INFO_MSG="" _c=$(count_selected) if [ "$_c" -gt 0 ]; then # --- bulk delete selected --- _prompt="Delete ${_c} selected items?" confirm_overlay; _yes=$? if [ "$_yes" -eq 0 ]; then _rest="$SELECTED" while [ -n "$_rest" ]; do _line="${_rest%% *}" _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue _name="${_line%@}"; _name="${_name%/}" _t="${CWD}/${_name}" if [ -d "$_t" ]; then rm -rf "$_t"; else rm -f "$_t"; fi done SELECTED="" [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -lt 0 ] && SEL=0 load_entries else NEED_FULL_REDRAW=1 fi else # --- single delete --- entry=$(get_entry "$SEL") _name="${entry%@}"; _name="${_name%/}" target="${CWD}/${_name}" _prompt="Delete \"${entry}\"?" confirm_overlay; _yes=$? if [ "$_yes" -ne 0 ]; then NEED_FULL_REDRAW=1; return fi if [ -d "$target" ]; then if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then _prompt="\"${entry}\" not empty. Delete ALL?" confirm_overlay; _yes2=$? if [ "$_yes2" -ne 0 ]; then NEED_FULL_REDRAW=1; return fi fi rm -rf "$target" else rm -f "$target" fi [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -lt 0 ] && SEL=0 load_entries fi } do_rename() { entry=$(get_entry "$SEL") _name="${entry%@}"; _name="${_name%/}" goto "$(term_rows)" 1 printf '%s%s Rename: %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "${RESET}" _init="$_name" if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$_name" ]; then mv "${CWD}/${_name}" "${CWD}/${READ_LINE}" load_entries else NEED_FULL_REDRAW=1; draw fi } do_mkdir() { goto "$(term_rows)" 1 printf '%s%s Directory name: %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}" if read_line && [ -n "$READ_LINE" ]; then mkdir -p "${CWD}/${READ_LINE}" load_entries else NEED_FULL_REDRAW=1; draw fi } do_chmod_x() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac _target=$(joinpath "${entry%@}") if chmod +x "$_target" 2>/dev/null; then INFO_MSG="chmod +x: ${entry}" else INFO_MSG="chmod failed: ${entry}" fi NEED_FULL_REDRAW=1 } do_chmod_nox() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac _target=$(joinpath "${entry%@}") if chmod -x "$_target" 2>/dev/null; then INFO_MSG="chmod -x: ${entry}" else INFO_MSG="chmod failed: ${entry}" fi NEED_FULL_REDRAW=1 } do_info() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return _name="${entry%@}"; _name="${_name%/}" target="${CWD}/${_name}" # gather info _info_perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}') _info_owner=$(ls -ld "$target" 2>/dev/null | awk '{print $3}') _info_group=$(ls -ld "$target" 2>/dev/null | awk '{print $4}') _info_size=$(du -sh "$target" 2>/dev/null | cut -f1) [ -z "$_info_size" ] && _info_size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}') _info_date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}') case "$entry" in */) _info_type="directory" ;; *@) _info_type="symlink -> $(ls -ld "$target" 2>/dev/null | awk '{print $NF}')" ;; *) _info_type="file" if [ -x "$target" ]; then _info_type="executable"; fi ;; esac _info_lines="" _info_lines="${_info_lines}Name: ${_name} " _info_lines="${_info_lines}Type: ${_info_type} " _info_lines="${_info_lines}Perm: ${_info_perm} " _info_lines="${_info_lines}Owner: ${_info_owner}:${_info_group} " _info_lines="${_info_lines}Size: ${_info_size} " _info_lines="${_info_lines}Mod: ${_info_date} " # measure longest line _maxw=0 _info_rest="$_info_lines" while [ -n "$_info_rest" ]; do _info_l="${_info_rest%% *}" [ "${#_info_l}" -gt "$_maxw" ] && _maxw=${#_info_l} _info_next="${_info_rest#* }" [ "$_info_next" = "$_info_rest" ] && break _info_rest="$_info_next" done _info_h=8 # 6 data rows + top + bottom ROWS=$(term_rows); COLS=$(term_cols) _iw=$((_maxw + 2)); [ "$_iw" -lt 20 ] && _iw=20 _bw=$((_iw + 2)); [ "$_bw" -gt "$COLS" ] && _bw=$COLS && _iw=$((_bw - 2)) _ix=$(( (COLS - _bw) / 2 )); [ "$_ix" -lt 0 ] && _ix=0 _iy=$(( (ROWS - _info_h) / 2 )); [ "$_iy" -lt 1 ] && _iy=1 _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') # draw goto "$_iy" "$_ix" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" goto "$((_iy+1))" "$_ix" _title=" FILE INFO" _tpad=$((_iw - ${#_title})) [ "$_tpad" -lt 0 ] && _tpad=0 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_title" "$(printf '%*s' "$_tpad" '')" "${BOLD}${CYAN}" "${RESET}" goto "$((_iy+2))" "$_ix" printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" _ri=3 _info_rest="$_info_lines" while [ -n "$_info_rest" ]; do _info_l="${_info_rest%% *}" _info_next="${_info_rest#* }" [ "$_info_next" = "$_info_rest" ] && break _info_rest="$_info_next" [ -z "$_info_l" ] && continue goto "$((_iy+_ri))" "$_ix" _lpad=$((_iw - ${#_info_l})) [ "$_lpad" -lt 0 ] && _lpad=0 printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_info_l" "$(printf '%*s' "$_lpad" '')" "${BOLD}${CYAN}" "${RESET}" _ri=$((_ri + 1)) done goto "$((_iy+_ri))" "$_ix" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" # wait for key IFS= read -r -n1 _infok 2>/dev/null || IFS= read -r _infok case "$_infok" in "$(printf '\033')") IFS= read -r -n5 -t 0.1 _infoseq 2>/dev/null || _infoseq="" ;; esac NEED_FULL_REDRAW=1 } do_shell() { restore_term; show_cursor printf '\033[2J\033[H' cd "$CWD" || true printf '%s(type "exit" to return to fm)%s\n' "${YELLOW}" "${RESET}" ${SHELL:-sh} # restore CWD in case user cd'd around CWD="${PWD}"; norm_cwd setup_term; hide_cursor NEED_FULL_REDRAW=1 } do_sort() { case "$SORT_MODE" in name) SORT_MODE="size" ;; size) SORT_MODE="date" ;; date) SORT_MODE="name" ;; esac INFO_MSG="sort: ${SORT_MODE}" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries } do_trash() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return _name="${entry%@}"; _name="${_name%/}" target="${CWD}/${_name}" _ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s') _dest="${TRASH_DIR}/${_ts}_${_name}" if mv "$target" "$_dest"; then INFO_MSG="trashed: ${entry}" [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -lt 0 ] && SEL=0 load_entries else INFO_MSG="trash failed" NEED_FULL_REDRAW=1 fi } do_open_trash() { PREV_CWD="$CWD" FILTER=""; SELECTED="" CWD="$TRASH_DIR"; norm_cwd SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries } do_copy_path() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return _name="${entry%@}"; _name="${_name%/}" _path="${CWD}/${_name}" if command -v wl-copy >/dev/null 2>&1; then printf '%s' "$_path" | wl-copy elif command -v xclip >/dev/null 2>&1; then printf '%s' "$_path" | xclip -selection clipboard elif command -v xsel >/dev/null 2>&1; then printf '%s' "$_path" | xsel --clipboard --input elif command -v pbcopy >/dev/null 2>&1; then printf '%s' "$_path" | pbcopy else INFO_MSG="no clipboard tool found" NEED_FULL_REDRAW=1 return fi INFO_MSG="path copied: ${_path}" NEED_FULL_REDRAW=1 } do_jump_back() { [ -z "$PREV_CWD" ] && { INFO_MSG="no previous directory"; NEED_FULL_REDRAW=1; return; } _tmp="$CWD" CWD="$PREV_CWD"; norm_cwd PREV_CWD="$_tmp" FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries } do_bookmark_add() { # toggle: if CWD already bookmarked, remove it; else add it if grep -qx "$CWD" "$BOOKMARK_FILE" 2>/dev/null; then # remove _tmp=$(grep -vx "$CWD" "$BOOKMARK_FILE") printf '%s\n' "$_tmp" > "$BOOKMARK_FILE" INFO_MSG="bookmark removed: ${CWD}" else printf '%s\n' "$CWD" >> "$BOOKMARK_FILE" INFO_MSG="bookmarked: ${CWD}" fi NEED_FULL_REDRAW=1 } do_bookmark_jump() { # count bookmarks _bc=0 while IFS= read -r _l; do [ -n "$_l" ] && _bc=$((_bc+1)); done < "$BOOKMARK_FILE" [ "$_bc" -eq 0 ] && { INFO_MSG="no bookmarks saved"; NEED_FULL_REDRAW=1; return; } # draw a small picker overlay ROWS=$(term_rows); COLS=$(term_cols) _pw=$(( COLS * 2 / 3 )); [ "$_pw" -gt 70 ] && _pw=70; [ "$_pw" -lt 30 ] && _pw=30 _ph=$(( _bc + 4 )); [ "$_ph" -gt $((ROWS - 4)) ] && _ph=$((ROWS - 4)) _px=$(( (COLS - _pw) / 2 )); [ "$_px" -lt 1 ] && _px=1 _py=$(( (ROWS - _ph) / 2 )); [ "$_py" -lt 1 ] && _py=1 _iw=$((_pw - 2)) _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') _bsel=1 _bvis=$((_ph - 3)) _boff=1 while true; do # draw picker inline goto "$_py" "$_px" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" goto "$((_py+1))" "$_px" _t=" BOOKMARKS"; _pl=$((_iw - ${#_t})); _p=$(printf '%*s' "$_pl" '') printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_t" "$_p" "${BOLD}${CYAN}" "${RESET}" goto "$((_py+2))" "$_px" printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" # clear visible rows first _bri=0 while [ "$_bri" -lt "$_bvis" ]; do goto "$((_py + 3 + _bri))" "$_px" _bep=$(printf '%*s' "$_iw" '') printf '%s|%s|%s' "${BOLD}${CYAN}" "$_bep" "${RESET}" _bri=$((_bri + 1)) done # draw visible bookmarks _bi=0 while IFS= read -r _bm; do [ -z "$_bm" ] && continue _bi=$((_bi+1)) [ "$_bi" -lt "$_boff" ] && continue [ "$_bi" -ge $((_boff + _bvis)) ] && continue _drow=$((_py + 2 + _bi - _boff + 1)) goto "$_drow" "$_px" _bt="${_bi} ${_bm}" if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi _bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '') if [ "$_bi" -eq "$_bsel" ]; then printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${REV}${YELLOW}" "$_bt" "$_bp" "${RESET}${BOLD}${CYAN}" "${RESET}" else printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_bt" "$_bp" "${BOLD}${CYAN}" "${RESET}" fi done < "$BOOKMARK_FILE" goto "$((_py+_ph))" "$_px" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk case "$_bk" in j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc [ "$_bsel" -ge $((_boff + _bvis)) ] && _boff=$((_bsel - _bvis + 1)) ;; k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 [ "$_bsel" -lt "$_boff" ] && _boff=$_bsel ;; "$(printf '\033')") # use timeout to distinguish bare esc from arrow sequences IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" case "$_seq" in '[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 [ "$_bsel" -lt "$_boff" ] && _boff=$_bsel ;; '[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc [ "$_bsel" -ge $((_boff + _bvis)) ] && _boff=$((_bsel - _bvis + 1)) ;; '[C') # right — open _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries else INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 fi return ;; '[D') NEED_FULL_REDRAW=1; return ;; # left — close *) NEED_FULL_REDRAW=1; return ;; # bare esc — close esac ;; "$(printf '\n')"|\ "$(printf '\r')"|\ l) _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0 load_entries else INFO_MSG="not found: ${_chosen}"; NEED_FULL_REDRAW=1 fi return ;; q|Q|h) NEED_FULL_REDRAW=1; return ;; esac done } do_toggle_select() { entry=$(get_entry "$SEL") [ -z "$entry" ] && return toggle_selected "$entry" _c=$(count_selected) [ "$_c" -eq 0 ] && INFO_MSG="selection cleared" || INFO_MSG="${_c} selected" SEL=$((SEL + 1)) NEED_FULL_REDRAW=1 } do_select_all() { _c=$(count_selected) if [ "$_c" -gt 0 ]; then # deselect all SELECTED="" INFO_MSG="selection cleared" else # select all visible entries (skip ../) SELECTED="" _rest="$ENTRIES" while [ -n "$_rest" ]; do _line="${_rest%% *}"; _next="${_rest#* }" [ "$_next" = "$_rest" ] && _next="" _rest="$_next" [ -z "$_line" ] && continue [ "$_line" = "../" ] && continue if [ -z "$SELECTED" ]; then SELECTED="$_line" else SELECTED="$SELECTED $_line"; fi done _c=$(count_selected) INFO_MSG="selected all: ${_c} items" fi NEED_FULL_REDRAW=1 } do_yank() { _c=$(count_selected) if [ "$_c" -gt 0 ]; then CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do [ -z "$_e" ] && continue _yname="${_e%@}"; _yname="${_yname%/}" printf '%s\n' "${CWD}/${_yname}" done) CLIP_MODE="copy" INFO_MSG="yanked ${_c} items" SELECTED="" else entry=$(get_entry "$SEL") [ -z "$entry" ] && return _yname="${entry%@}"; _yname="${_yname%/}" CLIPBOARD="${CWD}/${_yname}" CLIP_MODE="copy" INFO_MSG="yanked: ${entry}" fi NEED_FULL_REDRAW=1 } do_cut() { _c=$(count_selected) if [ "$_c" -gt 0 ]; then CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do [ -z "$_e" ] && continue _cname="${_e%@}"; _cname="${_cname%/}" printf '%s\n' "${CWD}/${_cname}" done) CLIP_MODE="cut" INFO_MSG="cut ${_c} items" SELECTED="" else entry=$(get_entry "$SEL") [ -z "$entry" ] && return _cname="${entry%@}"; _cname="${_cname%/}" CLIPBOARD="${CWD}/${_cname}" CLIP_MODE="cut" INFO_MSG="cut: ${entry}" fi NEED_FULL_REDRAW=1 } do_paste() { if [ -z "$CLIPBOARD" ]; then INFO_MSG="nothing to paste" NEED_FULL_REDRAW=1 return fi printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do [ -z "$_src" ] && continue _name="${_src##*/}" _dest="${CWD}/${_name}" if [ -e "$_dest" ]; then _base="${_name%.*}"; _ext="${_name##*.}" [ "$_ext" = "$_name" ] && _ext="" || _ext=".${_ext}" _dest="${CWD}/${_base}_copy${_ext}" fi case "$CLIP_MODE" in copy) cp -r "$_src" "$_dest" ;; cut) mv "$_src" "$_dest" ;; esac done CLIPBOARD=""; CLIP_MODE="" INFO_MSG="pasted" load_entries } # --- main --- trap 'restore_term; show_cursor; printf "\033[2J\033[H"; rm -f /tmp/_sfm_list_$$ /tmp/_sfm_find_$$; exit 0' INT QUIT TERM EXIT setup_term hide_cursor printf '\033[2J' # clear screen exactly once at startup load_entries while true; do draw key=$(read_key) case "$key" in j|"$(printf '\033[B')") [ "$SEL" -lt $((COUNT - 1)) ] && SEL=$((SEL + 1)) ;; k|"$(printf '\033[A')") [ "$SEL" -gt 0 ] && SEL=$((SEL - 1)) ;; g) SEL=0 ;; G) SEL=$((COUNT - 1)) ;; "$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;; "$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;; "$(printf '\033[3~')") do_delete ;; "$(printf '\n')"|\ "$(printf '\r')"|\ "$(printf '\033[C')"|\ l) do_open ;; "$(printf '\033[D')"|\ h) do_go_back ;; b) do_bookmark_add ;; B) do_bookmark_jump ;; '?') do_help ;; R) load_entries; INFO_MSG="refreshed" ;; /) do_search ;; '.') do_toggle_hidden ;; T) do_toggle_details ;; P) do_toggle_preview ;; i) do_info ;; '+') do_chmod_x ;; '-') do_chmod_nox ;; o) do_open_with ;; s) do_sort ;; u) do_trash ;; U) do_open_trash ;; f) do_find ;; ':') do_jump_path ;; '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED="" SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;; c) do_copy_path ;; "$(printf '\033')") do_clear_filter ;; ' ') do_toggle_select ;; a) do_select_all ;; y) do_yank ;; x) do_cut ;; p) do_paste ;; d) do_delete ;; r) do_rename ;; m) do_mkdir ;; n) do_newfile ;; q|Q) break ;; '!') do_shell ;; esac done restore_term show_cursor printf '\033[2J\033[H' printf '%s\n' "$CWD"