From 40e5d2815cc6cdff95751af9377ebe3ff33daf81 Mon Sep 17 00:00:00 2001 From: emmett1 Date: Tue, 16 Jun 2026 07:43:51 +0800 Subject: added cpp version, sh version as backup --- .gitignore | 2 + Makefile | 21 +- README | 39 +- sfm | 1870 ----------------------------------------------- sfm.cpp | 2374 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ sfm.sh | 1870 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 4285 insertions(+), 1891 deletions(-) create mode 100644 .gitignore delete mode 100755 sfm create mode 100644 sfm.cpp create mode 100755 sfm.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe26b51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +CLAUDE.md +sfm diff --git a/Makefile b/Makefile index 3e12ff4..1dc0167 100644 --- a/Makefile +++ b/Makefile @@ -5,33 +5,42 @@ BINDIR ?= $(PREFIX)/bin MANDIR ?= $(PREFIX)/share/man/man1 DOCDIR ?= $(PREFIX)/share/doc/sfm +CXX ?= g++ +CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra +LDFLAGS ?= -lncursesw + INSTALL ?= install RM ?= rm -f .PHONY: all install uninstall clean -all: - @echo "sfm is a shell script — nothing to build." - @echo "Run 'make install' to install." +all: sfm + +sfm: sfm.cpp + $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS) -install: +install: all @echo "Installing sfm to $(DESTDIR)$(BINDIR)/sfm ..." $(INSTALL) -d $(DESTDIR)$(BINDIR) $(INSTALL) -m 755 sfm $(DESTDIR)$(BINDIR)/sfm + @echo "Installing sfm.sh to $(DESTDIR)$(BINDIR)/sfm.sh ..." + $(INSTALL) -m 755 sfm.sh $(DESTDIR)$(BINDIR)/sfm.sh @echo "Installing man page to $(DESTDIR)$(MANDIR)/sfm.1 ..." $(INSTALL) -d $(DESTDIR)$(MANDIR) $(INSTALL) -m 644 sfm.1 $(DESTDIR)$(MANDIR)/sfm.1 @echo "Installing README to $(DESTDIR)$(DOCDIR)/README ..." $(INSTALL) -d $(DESTDIR)$(DOCDIR) $(INSTALL) -m 644 README $(DESTDIR)$(DOCDIR)/README - @echo "Done. Run 'sfm' to start, or 'man sfm' for help." + @echo "Done." uninstall: @echo "Removing sfm ..." $(RM) $(DESTDIR)$(BINDIR)/sfm + $(RM) $(DESTDIR)$(BINDIR)/sfm.sh $(RM) $(DESTDIR)$(MANDIR)/sfm.1 $(RM) -r $(DESTDIR)$(DOCDIR) @echo "Done." clean: - @echo "Nothing to clean." + $(RM) sfm + @echo "Cleaned." diff --git a/README b/README index 36d2baa..310bfe4 100644 --- a/README +++ b/README @@ -4,16 +4,23 @@ sfm - Simple File Manager DESCRIPTION ----------- -sfm is a lightweight, terminal-based file manager written entirely in -POSIX sh. It runs in your terminal with no external dependencies beyond -standard Unix tools (ls, awk, tput, stty, mv, cp, rm). Designed to be -fast, flicker-free, and keyboard-driven with a vim-inspired key layout. +sfm is a lightweight, terminal-based file manager with a vim-inspired +key layout. The primary version is a fast C++/ncurses binary. A portable +POSIX sh version (sfm.sh) is also included, requiring only standard Unix +tools (ls, awk, tput, stty, mv, cp, rm). REQUIREMENTS ------------ +C++ version (sfm): +- ncursesw (libncursesw) +- A C++17 compiler (g++ or compatible) + +Shell version (sfm.sh): - A POSIX-compatible shell (sh, dash, bash, etc.) - Standard Unix tools: ls, awk, tput, stty, cp, mv, rm, mkdir, touch + +Both versions: - Optional: wl-copy / xclip / xsel / pbcopy (for clipboard support) - Optional: mpv, vlc, feh, zathura, etc. (for smart file opening) - Optional: readlink (for symlink display) @@ -23,21 +30,23 @@ REQUIREMENTS INSTALLATION ------------ Using make (recommended): - + make install - -This installs sfm to /usr/local/bin by default. To change the prefix: - + +This builds the C++ binary and installs both sfm (C++ binary) +and sfm.sh (shell script) to /usr/local/bin by default. +To change the prefix: + make install PREFIX=/usr - + To uninstall: - + make uninstall - -Manual installation: - - cp sfm /usr/local/bin/sfm - chmod +x /usr/local/bin/sfm + +Manual installation (shell version only): + + cp sfm.sh /usr/local/bin/sfm.sh + chmod +x /usr/local/bin/sfm.sh USAGE diff --git a/sfm b/sfm deleted file mode 100755 index 032b6e5..0000000 --- a/sfm +++ /dev/null @@ -1,1870 +0,0 @@ -#!/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" diff --git a/sfm.cpp b/sfm.cpp new file mode 100644 index 0000000..9913b62 --- /dev/null +++ b/sfm.cpp @@ -0,0 +1,2374 @@ +// sfm - Simple File Manager in C++17 with ncurses +// Rewrite of the POSIX sh original for speed and efficiency +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// ─── terminal helpers ─────────────────────────────────────────────────────── +[[nodiscard]] bool can_color() { return has_colors(); } + +// Color pair indices +enum { + CP_DIR = 1, + CP_VALID_SYMLINK, + CP_BROKEN_SYMLINK, + CP_EXECUTABLE, + CP_NORMAL, + CP_TOPBAR, + CP_DIVIDER, + CP_MARKER, + CP_INFO, + CP_OVERLAY_BORDER, + CP_OVERLAY_TITLE, + CP_BUTTON_YES, + CP_BUTTON_NO, +}; + +#define ATTR_DIR (COLOR_PAIR(CP_DIR) | A_BOLD) +#define ATTR_VALID_SYMLINK (COLOR_PAIR(CP_VALID_SYMLINK) | A_BOLD) +#define ATTR_BROKEN_SYMLINK (COLOR_PAIR(CP_BROKEN_SYMLINK) | A_BOLD) +#define ATTR_EXECUTABLE (COLOR_PAIR(CP_EXECUTABLE) | A_BOLD) +#define ATTR_NORMAL COLOR_PAIR(CP_NORMAL) +#define ATTR_TOPBAR (COLOR_PAIR(CP_TOPBAR) | A_BOLD) +#define ATTR_DIVIDER COLOR_PAIR(CP_DIVIDER) +#define ATTR_MARKER COLOR_PAIR(CP_MARKER) +#define ATTR_INFO COLOR_PAIR(CP_INFO) +#define ATTR_OVERLAY_BORDER COLOR_PAIR(CP_OVERLAY_BORDER) + +// ─── Entry data ───────────────────────────────────────────────────────────── +enum class EntryType { Directory, File, Symlink }; + +struct Entry { + std::string name; // display name (dirs end with '/', symlinks with '@') + std::string full_path; // absolute canonical path + EntryType type = EntryType::File; + bool is_hidden = false; + bool is_executable = false; + off_t size = 0; + timespec mtime{}; + std::string symlink_target; // empty unless type == Symlink + std::string permissions; // e.g. "rwxr-xr-x" + std::string owner; + std::string group; +}; + +// ─── utilities ────────────────────────────────────────────────────────────── +static std::string home_dir() { + const char *h = getenv("HOME"); + return h ? h : "/"; +} + +static std::string config_dir() { + const char *xdg = getenv("XDG_CONFIG_HOME"); + return xdg ? std::string(xdg) + "/sfm" : home_dir() + "/.config/sfm"; +} + +static std::string data_dir() { + const char *xdg = getenv("XDG_DATA_HOME"); + return xdg ? std::string(xdg) + "/sfm-trash" : home_dir() + "/.local/share/sfm-trash"; +} + +// Join CWD (already absolute) with a name, avoiding double-slash at root. +static std::string join_path(const std::string &cwd, const std::string &name) { + if (cwd == "/") return "/" + name; + return cwd + "/" + name; +} + +// Escape a string for safe use in a shell command (single-quote wrapping). +static std::string shell_escape(const std::string &s) { + std::string r = "'"; + for (char c : s) { + if (c == '\'') r += "'\\''"; + else r += c; + } + r += "'"; + return r; +} + +// Lowercase a string (ASCII only — fast path for extension matching). +static std::string lower(const std::string &s) { + std::string r = s; + for (auto &c : r) c = static_cast(std::tolower(static_cast(c))); + return r; +} + +// Get file extension (lowercase, without dot) — empty if none. +static std::string file_ext(const std::string &name) { + auto pos = name.rfind('.'); + if (pos == std::string::npos || pos == 0) return ""; + return lower(name.substr(pos + 1)); +} + +// Strip a suffix from a string (e.g., "foo/" -> "foo"). +static std::string strip_suffix(std::string s, const char *suf) { + size_t len = std::strlen(suf); + if (s.size() >= len && s.compare(s.size() - len, len, suf) == 0) + s.erase(s.size() - len); + return s; +} + +// Forward — defined before draw_preview, used by render_row too. +static std::string sanitize_display(const std::string &s); + +// ─── FileManager ──────────────────────────────────────────────────────────── +class FileManager { +public: + FileManager(const std::string &start_path); + ~FileManager(); + void run(); + +private: + // ── state ────────────────────────────────────────────────────────────── + std::string cwd_; + std::string prev_cwd_; // for backtick toggle + std::string last_child_; // name (with trailing /) of dir we came from + + std::vector all_entries_; // source of truth (unfiltered) + std::vector entries_; // filtered + sorted display list + + int sel_ = 0, offset_ = 0; + int prev_sel_ = -1, prev_offset_ = -1; + bool need_full_redraw_ = true; + + bool show_hidden_ = false; + bool show_details_ = false; + bool show_preview_ = false; + bool searching_ = false; + std::string filter_; + std::string info_msg_; + + enum class SortMode { Name, Size, Date } sort_mode_ = SortMode::Name; + + std::unordered_set selected_; // entry display names + std::vector clipboard_paths_; // absolute paths + enum class ClipMode { None, Copy, Cut } clip_mode_ = ClipMode::None; + + int rows_ = 0, cols_ = 0; + int list_cols_ = 0, prev_col_ = 0, prev_width_ = 0; + + std::string bookmark_file_; + std::string trash_dir_; + std::string opener_path_; + + // ── terminal ─────────────────────────────────────────────────────────── + void setup_term(); + void restore_term(); + void update_size(); + void init_colors(); + + // ── directory loading ────────────────────────────────────────────────── + void load_entries(); + void apply_filter(); + void sort_entries(); + int find_entry_idx(const std::string &name) const; + const Entry* get_sel() const; + + // ── rendering ────────────────────────────────────────────────────────── + void draw(); + void draw_topbar(); + void draw_botbar(); + void draw_file_list(); + void draw_preview(); + void render_row(int row, int idx, bool selected, int cols); + + // ── overlays ─────────────────────────────────────────────────────────── + bool confirm_overlay(const std::string &prompt); + std::string overlay_picker(const std::string &title, + const std::vector &items, + bool allow_cancel = true); + + // ── line input ───────────────────────────────────────────────────────── + bool read_line(std::string &out, const std::string &prompt, + const std::string &initial = ""); + + // ── actions ──────────────────────────────────────────────────────────── + void do_open(); + void do_go_back(); + void do_go_home(); + void do_jump_back(); + void do_jump_path(); + void do_search(); + void do_clear_filter(); + void do_toggle_hidden(); + void do_toggle_details(); + void do_toggle_preview(); + void do_sort(); + void do_info(); + void do_help(); + void do_rename(); + void do_mkdir(); + void do_newfile(); + void do_delete(); + void do_trash(); + void do_open_trash(); + void do_open_with(); + void do_chmod_x(bool set); + void do_find(); + void do_bookmark_add(); + void do_bookmark_jump(); + void do_copy_path(); + void do_toggle_select(); + void do_select_all(); + void do_yank(); + void do_cut(); + void do_paste(); + void do_shell(); + + // ── helpers ──────────────────────────────────────────────────────────── + void open_file(const std::string &path); + void run_tty(const char *cmd, char *const argv[]); + void run_gui(const char *cmd, char *const argv[]); + bool try_run(const std::vector &progs, const std::string &path, + bool gui); + void change_dir(const std::string &path); + void flash_msg(const std::string &msg) { info_msg_ = msg; need_full_redraw_ = true; } +}; + +// ─── constructor / destructor ─────────────────────────────────────────────── +FileManager::FileManager(const std::string &start_path) { + // Derive config/data paths early before ncurses takes over + bookmark_file_ = config_dir() + "/bookmarks"; + trash_dir_ = data_dir(); + opener_path_ = config_dir() + "/opener"; + + // Ensure config and trash directories exist + std::error_code ec; + fs::create_directories(config_dir(), ec); + fs::create_directories(trash_dir_, ec); + // Touch bookmark file if missing + if (!fs::exists(bookmark_file_)) { + std::ofstream(bookmark_file_, std::ios::app).close(); + } + + // Resolve starting directory + if (!start_path.empty()) { + std::error_code ec2; + cwd_ = fs::canonical(start_path, ec2); + if (ec2) { + std::fprintf(stderr, "sfm: %s: no such directory\n", start_path.c_str()); + std::exit(1); + } + } else { + cwd_ = fs::current_path(); + } +} + +FileManager::~FileManager() { + restore_term(); +} + +// ─── terminal setup / teardown ────────────────────────────────────────────── +void FileManager::setup_term() { + initscr(); + raw(); + noecho(); + keypad(stdscr, TRUE); + set_escdelay(25); // minimal ESC delay — still catches arrow keys + curs_set(0); + leaveok(stdscr, TRUE); + if (can_color()) init_colors(); + update_size(); +} + +void FileManager::restore_term() { + if (!isendwin()) { + curs_set(1); + endwin(); + } +} + +void FileManager::update_size() { + rows_ = getmaxy(stdscr); + cols_ = getmaxx(stdscr); + if (rows_ < 1) rows_ = 24; + if (cols_ < 1) cols_ = 80; +} + +void FileManager::init_colors() { + start_color(); + use_default_colors(); // enables -1 as "default bg" in init_pair + + init_pair(CP_DIR, COLOR_BLUE, -1); + init_pair(CP_VALID_SYMLINK, COLOR_CYAN, -1); + init_pair(CP_BROKEN_SYMLINK, COLOR_RED, -1); + init_pair(CP_EXECUTABLE, COLOR_GREEN, -1); + init_pair(CP_NORMAL, COLOR_WHITE, -1); + init_pair(CP_TOPBAR, COLOR_CYAN, -1); + init_pair(CP_DIVIDER, COLOR_CYAN, -1); + init_pair(CP_MARKER, COLOR_YELLOW, -1); + init_pair(CP_INFO, COLOR_YELLOW, -1); + init_pair(CP_OVERLAY_BORDER, COLOR_CYAN, -1); + init_pair(CP_OVERLAY_TITLE, COLOR_CYAN, -1); + init_pair(CP_BUTTON_YES, COLOR_GREEN, -1); + init_pair(CP_BUTTON_NO, COLOR_RED, -1); +} + +// ─── directory loading ────────────────────────────────────────────────────── +void FileManager::load_entries() { + all_entries_.clear(); + + DIR *dir = opendir(cwd_.c_str()); + if (!dir) { + flash_msg("cannot open directory"); + // fall back to parent if possible + if (cwd_ != "/") { + cwd_ = fs::path(cwd_).parent_path(); + load_entries(); + } + return; + } + + struct dirent *de; + while ((de = readdir(dir)) != nullptr) { + std::string name(de->d_name); + if (name == ".") continue; + + bool hidden = (name[0] == '.'); + if (!show_hidden_ && hidden) continue; + + Entry e; + e.name = name; + e.full_path = join_path(cwd_, name); + e.is_hidden = hidden; + + struct stat st; + if (lstat(e.full_path.c_str(), &st) != 0) continue; + + if (S_ISDIR(st.st_mode)) { + e.type = EntryType::Directory; + e.name += '/'; + } else if (S_ISLNK(st.st_mode)) { + e.type = EntryType::Symlink; + e.name += '@'; + char buf[PATH_MAX]; + ssize_t len = readlink(e.full_path.c_str(), buf, sizeof(buf) - 1); + if (len > 0) { buf[len] = '\0'; e.symlink_target = buf; } + } else { + e.type = EntryType::File; + } + + e.size = st.st_size; + e.mtime = st.st_mtim; + e.is_executable = (st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0; + + // Permissions string + char perm[11] = "----------"; + if (S_ISDIR(st.st_mode)) perm[0] = 'd'; + if (S_ISLNK(st.st_mode)) perm[0] = 'l'; + if (st.st_mode & S_IRUSR) perm[1] = 'r'; + if (st.st_mode & S_IWUSR) perm[2] = 'w'; + if (st.st_mode & S_IXUSR) perm[3] = 'x'; + if (st.st_mode & S_IRGRP) perm[4] = 'r'; + if (st.st_mode & S_IWGRP) perm[5] = 'w'; + if (st.st_mode & S_IXGRP) perm[6] = 'x'; + if (st.st_mode & S_IROTH) perm[7] = 'r'; + if (st.st_mode & S_IWOTH) perm[8] = 'w'; + if (st.st_mode & S_IXOTH) perm[9] = 'x'; + e.permissions = perm; + + // Owner / group + struct passwd *pw = getpwuid(st.st_uid); + e.owner = pw ? pw->pw_name : std::to_string(st.st_uid); + struct group *gr = getgrgid(st.st_gid); + e.group = gr ? gr->gr_name : std::to_string(st.st_gid); + + all_entries_.push_back(std::move(e)); + } + closedir(dir); + + sort_entries(); + apply_filter(); + need_full_redraw_ = true; +} + +void FileManager::sort_entries() { + std::stable_sort(all_entries_.begin(), all_entries_.end(), + [this](const Entry &a, const Entry &b) { + // Hidden items first (when shown), then dirs, then files + if (a.is_hidden != b.is_hidden) + return a.is_hidden; // hidden first + if (a.type != b.type) { + if (a.type == EntryType::Directory) return true; + if (b.type == EntryType::Directory) return false; + } + + switch (sort_mode_) { + case SortMode::Name: + return strcoll(a.name.c_str(), b.name.c_str()) < 0; + case SortMode::Size: + if (a.size != b.size) return a.size > b.size; + return strcoll(a.name.c_str(), b.name.c_str()) < 0; + case SortMode::Date: + if (a.mtime.tv_sec != b.mtime.tv_sec) + return a.mtime.tv_sec > b.mtime.tv_sec; + if (a.mtime.tv_nsec != b.mtime.tv_nsec) + return a.mtime.tv_nsec > b.mtime.tv_nsec; + return strcoll(a.name.c_str(), b.name.c_str()) < 0; + } + return false; + }); +} + +void FileManager::apply_filter() { + entries_.clear(); + if (filter_.empty()) { + entries_ = all_entries_; + } else { + for (const auto &e : all_entries_) { + if (e.name.find(filter_) != std::string::npos) + entries_.push_back(e); + } + } +} + +int FileManager::find_entry_idx(const std::string &name) const { + for (size_t i = 0; i < entries_.size(); ++i) { + if (entries_[i].name == name) return static_cast(i); + } + return -1; +} + +const Entry* FileManager::get_sel() const { + if (entries_.empty() || sel_ < 0 || sel_ >= static_cast(entries_.size())) + return nullptr; + return &entries_[sel_]; +} + +// ─── rendering ────────────────────────────────────────────────────────────── +void FileManager::draw() { + update_size(); + + // Layout calculation + if (show_preview_) { + list_cols_ = cols_ / 2; + prev_col_ = list_cols_ + 2; + prev_width_ = cols_ - list_cols_ - 2; + } else { + list_cols_ = cols_; + prev_width_ = 0; + prev_col_ = 0; + } + + int list_rows = rows_ - 2; // rows 0 = topbar, rows-1 = botbar + if (list_rows < 1) list_rows = 1; + + // Clamp selection + if (sel_ < 0) sel_ = 0; + if (!entries_.empty() && sel_ >= static_cast(entries_.size())) + sel_ = static_cast(entries_.size()) - 1; + + // Clamp scroll offset + if (sel_ < offset_) offset_ = sel_; + else if (sel_ >= offset_ + list_rows) offset_ = sel_ - list_rows + 1; + if (offset_ < 0) offset_ = 0; + + // Detect offset change + if (offset_ != prev_offset_) need_full_redraw_ = true; + + if (need_full_redraw_) { + erase(); + draw_topbar(); + draw_file_list(); + if (show_preview_) draw_preview(); + draw_botbar(); + } else if (sel_ != prev_sel_ || offset_ != prev_offset_) { + // Fast path: only re-render the two changed rows + int prev_row = (prev_sel_ - offset_) + 1; + int curr_row = (sel_ - offset_) + 1; + + // Un-highlight old row + if (prev_row >= 1 && prev_row <= list_rows) { + int pidx = prev_sel_ - offset_; + if (pidx >= 0 && pidx < offset_ + list_rows && pidx < static_cast(entries_.size())) + render_row(prev_row, prev_sel_, false, list_cols_); + } + // Highlight new row + if (curr_row >= 1 && curr_row <= list_rows) { + render_row(curr_row, sel_, true, list_cols_); + } + + if (show_preview_) draw_preview(); + draw_botbar(); + } + + prev_sel_ = sel_; + prev_offset_ = offset_; + need_full_redraw_ = false; + + refresh(); +} + +void FileManager::draw_topbar() { + attron(ATTR_TOPBAR); + for (int c = 0; c < cols_; ++c) mvaddch(0, c, ' '); + attroff(ATTR_TOPBAR); +} + +void FileManager::draw_botbar() { + std::string left, right; + + if (searching_) { + left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/" + + std::to_string(entries_.size()) + " " + cwd_; + right = " search: " + filter_ + " "; + } else if (!info_msg_.empty()) { + left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/" + + std::to_string(entries_.size()) + " " + cwd_; + right = " " + info_msg_ + " "; + info_msg_.clear(); + } else { + std::string indicators; + if (clip_mode_ == ClipMode::Copy) indicators += " [copy]"; + else if (clip_mode_ == ClipMode::Cut) indicators += " [cut]"; + auto sc = selected_.size(); + if (sc > 0) indicators += " [sel:" + std::to_string(sc) + "]"; + if (show_hidden_) indicators += " [hidden]"; + if (sort_mode_ != SortMode::Name) { + indicators += " [sort:"; + indicators += (sort_mode_ == SortMode::Size) ? "size]" : "date]"; + } + if (show_details_) indicators += " [details]"; + + left = " " + std::to_string(entries_.empty() ? 0 : sel_ + 1) + "/" + + std::to_string(entries_.size()) + " " + cwd_ + indicators; + right = " press ? for help "; + } + + int total = static_cast(left.size() + right.size()); + int pad = cols_ - total; + if (pad < 0) pad = 0; + + attron(ATTR_TOPBAR); + mvprintw(rows_ - 1, 0, "%s", left.c_str()); + for (int i = 0; i < pad; ++i) mvaddch(rows_ - 1, static_cast(left.size()) + i, ' '); + attroff(ATTR_TOPBAR); + + attron(ATTR_INFO); + mvprintw(rows_ - 1, cols_ - static_cast(right.size()), "%s", right.c_str()); + attroff(ATTR_INFO); +} + +void FileManager::draw_file_list() { + int list_rows = rows_ - 2; + int idx = offset_; + for (int row = 1; row <= list_rows && idx < static_cast(entries_.size()); ++row, ++idx) { + render_row(row, idx, idx == sel_, list_cols_); + } + // Show "(empty)" if no entries + if (entries_.empty()) { + attron(ATTR_NORMAL); + mvaddstr(1, 0, " (empty)"); + attroff(ATTR_NORMAL); + } +} + +void FileManager::render_row(int row, int idx, bool highlighted, int cols) { + if (idx < 0 || idx >= static_cast(entries_.size())) return; + const Entry &e = entries_[idx]; + + // Determine colour+attribute based on type + attr_t attr = ATTR_NORMAL; + if (e.type == EntryType::Directory) attr = ATTR_DIR; + else if (e.type == EntryType::Symlink) { + // Check if broken + struct stat dummy; + if (stat(e.full_path.c_str(), &dummy) == 0) + attr = ATTR_VALID_SYMLINK; + else + attr = ATTR_BROKEN_SYMLINK; + } else if (e.is_executable) attr = ATTR_EXECUTABLE; + + if (highlighted) attr |= A_REVERSE; + + // Build detail string (if enabled) + std::string detail; + if (show_details_) { + char buf[64]; + // Size + double sz = static_cast(e.size); + const char *units = "B"; + if (sz >= 1024) { sz /= 1024; units = "K"; } + if (sz >= 1024) { sz /= 1024; units = "M"; } + if (sz >= 1024) { sz /= 1024; units = "G"; } + // Date + char date_buf[32]; + struct tm tm_val; + localtime_r(&e.mtime.tv_sec, &tm_val); + std::strftime(date_buf, sizeof(date_buf), "%b %d %H:%M", &tm_val); + + std::snprintf(buf, sizeof(buf), " %5.0f%s %s", sz, units, date_buf); + detail = buf; + } + + // Build display name (sanitize — filenames can contain control chars) + std::string display; + if (e.type == EntryType::Symlink && !e.symlink_target.empty()) { + display = sanitize_display(e.name + " -> " + e.symlink_target); + struct stat _st; + if (stat(e.full_path.c_str(), &_st) != 0) + display += " [broken]"; + } else { + display = sanitize_display(e.name); + } + + // Multi-select marker + bool marked = selected_.count(e.name); + char marker = marked ? '*' : ' '; + + // Calculate available width + int dlen = static_cast(detail.size()); + int maxw = cols - 2 - dlen; + if (maxw < 4) maxw = 4; + + // Truncate display name if needed + std::string show = display; + if (static_cast(show.size()) > maxw) { + show.resize(maxw > 3 ? maxw - 3 : 0); + show += "..."; + } + + int name_w = static_cast(show.size()) + 1; // +1 for marker + int pad = cols - name_w - dlen; + + // Truncate detail if it overflows past cols (can happen with narrow + // preview pane + detail mode) + std::string detail_str; + int dlen_actual = 0; + if (!detail.empty()) { + if (pad < 0) { + int avail = cols - name_w; + if (avail > 3) { + detail_str = detail.substr(0, avail - 3) + "..."; + } else if (avail > 0) { + detail_str = detail.substr(0, avail); + } + dlen_actual = static_cast(detail_str.size()); + pad = cols - name_w - dlen_actual; + } else { + detail_str = detail; + dlen_actual = dlen; + } + if (pad < 0) pad = 0; + } + + if (marked) { + attron(ATTR_MARKER); + mvaddch(row, 0, marker); + attroff(ATTR_MARKER); + } else { + mvaddch(row, 0, marker); + } + + attron(attr); + mvaddstr(row, 1, show.c_str()); + for (int p = 0; p < pad; ++p) + mvaddch(row, 1 + static_cast(show.size()) + p, ' '); + if (!detail_str.empty()) { + attron(attr & ~A_REVERSE); + if (highlighted) attron(A_REVERSE); + mvaddstr(row, 1 + static_cast(show.size()) + pad, detail_str.c_str()); + } + attroff(attr); +} + +// Allow only printable ASCII (32–126) and tab; replace everything else with '.'. +// This keeps ANSI escapes, control chars, and high bytes (>127) from reaching +// the terminal and corrupting the display. +static std::string sanitize_display(const std::string &s) { + std::string r; + r.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + unsigned char c = static_cast(s[i]); + if (c >= 32 && c < 127) { r += s[i]; continue; } + if (c == '\t') { r += ' '; continue; } + r += '.'; + } + return r; +} + +void FileManager::draw_preview() { + const Entry *e = get_sel(); + if (!e) return; + + // Draw vertical divider + for (int r = 1; r < rows_ - 1; ++r) { + attron(ATTR_DIVIDER); + mvaddch(r, list_cols_, '|'); + attroff(ATTR_DIVIDER); + } + + // Clear entire preview area of previous content (stale lines from last preview) + for (int r = 1; r < rows_ - 1; ++r) { + move(r, prev_col_); + clrtoeol(); + } + + int px = prev_col_; + int pw = prev_width_; + int max_lines = rows_ - 3; + + // Resolve real path for symlinks/regular files + std::string real_path = e->full_path; + + switch (e->type) { + case EntryType::Directory: { + // Collect and sort: subdirs first, then files + std::vector subdirs, files; + std::error_code ec; + for (const auto &de : fs::directory_iterator(real_path, ec)) { + std::string n = de.path().filename().string(); + if (n.empty()) continue; + if (de.is_directory()) { + subdirs.push_back(n + "/"); + } else { + files.push_back(n); + } + } + std::sort(subdirs.begin(), subdirs.end()); + std::sort(files.begin(), files.end()); + + int line = 0; + for (const auto &n : subdirs) { + if (line >= max_lines) break; + std::string show = n; + if (static_cast(show.size()) > pw - 1) + show = show.substr(0, pw - 2) + "~"; + attron(ATTR_DIR); + mvprintw(1 + line, px, " %s", sanitize_display(show).c_str()); + attroff(ATTR_DIR); + line++; + } + for (const auto &n : files) { + if (line >= max_lines) break; + std::string show = n; + if (static_cast(show.size()) > pw - 1) + show = show.substr(0, pw - 2) + "~"; + attron(ATTR_NORMAL); + mvprintw(1 + line, px, " %s", sanitize_display(show).c_str()); + attroff(ATTR_NORMAL); + line++; + } + if (line == 0) { + attron(ATTR_NORMAL); + mvprintw(1, px, " (empty)"); + attroff(ATTR_NORMAL); + } + break; + } + case EntryType::Symlink: { + attron(ATTR_VALID_SYMLINK); + mvprintw(1, px, " [symlink]"); + attroff(ATTR_VALID_SYMLINK); + attron(ATTR_NORMAL); + mvprintw(2, px, " -> %s", sanitize_display(e->symlink_target).c_str()); + attroff(ATTR_NORMAL); + break; + } + case EntryType::File: { + // Detect if text file + bool is_text = false; + std::string ext = file_ext(e->name); + + // Extension whitelist + static const std::unordered_set text_exts = { + "txt","md","markdown","rst","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","csv","tsv" + }; + if (text_exts.count(ext)) { + is_text = true; + } else { + // Fallback: check with `file` command + std::string cmd = "file --mime-type -b " + shell_escape(real_path) + " 2>/dev/null"; + FILE *fp = popen(cmd.c_str(), "r"); + if (fp) { + char buf[256]; + if (fgets(buf, sizeof(buf), fp)) { + std::string mime(buf); + if (mime.find("text/") == 0 || mime.find("application/json") == 0 + || mime.find("application/xml") == 0 + || mime.find("application/javascript") == 0) + is_text = true; + } + pclose(fp); + } + } + + if (is_text) { + // Show file contents + std::ifstream f(real_path); + if (f.is_open()) { + std::string line_str; + int line_no = 0; + while (line_no < max_lines && std::getline(f, line_str)) { + if (static_cast(line_str.size()) > pw - 1) + line_str = line_str.substr(0, pw - 2) + "~"; + attron(ATTR_NORMAL); + mvprintw(1 + line_no, px, " %s", sanitize_display(line_str).c_str()); + attroff(ATTR_NORMAL); + line_no++; + } + if (line_no == 0) { + attron(ATTR_NORMAL); + mvprintw(1, px, " (empty file)"); + attroff(ATTR_NORMAL); + } + } else { + attron(ATTR_NORMAL); + mvprintw(1, px, " (cannot read file)"); + attroff(ATTR_NORMAL); + } + } else { + // Binary file — show size + double sz = static_cast(e->size); + const char *units = "B"; + if (sz >= 1024) { sz /= 1024; units = "K"; } + if (sz >= 1024) { sz /= 1024; units = "M"; } + if (sz >= 1024) { sz /= 1024; units = "G"; } + attron(ATTR_INFO); + mvprintw(1, px, " [binary] %.0f %s", sz, units); + attroff(ATTR_INFO); + } + break; + } + } +} + +// ─── overlays ─────────────────────────────────────────────────────────────── +bool FileManager::confirm_overlay(const std::string &prompt) { + int bw = static_cast(prompt.size()) + 10; + if (bw < 32) bw = 32; + if (bw > cols_ - 4) bw = cols_ - 4; + int bh = 5; + int bx = (cols_ - bw) / 2; + int by = (rows_ - bh) / 2; + if (bx < 0) bx = 0; + if (by < 1) by = 1; + + int iw = bw - 2; + bool sel_yes = false; + + const char *yes_lbl = "[ Yes ]"; + const char *no_lbl = " [ No ]"; + int yes_w = static_cast(std::strlen(yes_lbl)); + int no_w = static_cast(std::strlen(no_lbl)); + int btns_w = yes_w + no_w; + + while (true) { + // Clear overlay area (erase old content) + for (int r = 0; r < bh; ++r) + for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' '); + + // ACS box borders + attron(ATTR_OVERLAY_BORDER); + mvaddch(by, bx, ACS_ULCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE); + mvaddch(by, bx + bw - 1, ACS_URCORNER); + + mvaddch(by + 1, bx, ACS_VLINE); + mvaddch(by + 1, bx + bw - 1, ACS_VLINE); + + mvaddch(by + 2, bx, ACS_LTEE); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE); + mvaddch(by + 2, bx + bw - 1, ACS_RTEE); + + mvaddch(by + 3, bx, ACS_VLINE); + mvaddch(by + 3, bx + bw - 1, ACS_VLINE); + + mvaddch(by + 4, bx, ACS_LLCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + 4, bx + i, ACS_HLINE); + mvaddch(by + 4, bx + bw - 1, ACS_LRCORNER); + attroff(ATTR_OVERLAY_BORDER); + + // Prompt + attron(ATTR_NORMAL); + mvprintw(by + 1, bx + 1, " %-*s ", iw - 2, prompt.c_str()); + attroff(ATTR_NORMAL); + + // Buttons + int pad = (iw - btns_w) / 2; + if (pad < 0) pad = 0; + + if (sel_yes) { + attron(COLOR_PAIR(CP_BUTTON_YES) | A_BOLD); + mvaddstr(by + 3, bx + 1 + pad, yes_lbl); + attroff(COLOR_PAIR(CP_BUTTON_YES) | A_BOLD); + } else { + attron(ATTR_NORMAL); + mvaddstr(by + 3, bx + 1 + pad, yes_lbl); + attroff(ATTR_NORMAL); + } + if (!sel_yes) { + attron(COLOR_PAIR(CP_BUTTON_NO) | A_BOLD); + mvaddstr(by + 3, bx + 1 + pad + yes_w, no_lbl); + attroff(COLOR_PAIR(CP_BUTTON_NO) | A_BOLD); + } else { + attron(ATTR_NORMAL); + mvaddstr(by + 3, bx + 1 + pad + yes_w, no_lbl); + attroff(ATTR_NORMAL); + } + + refresh(); + + int ch = getch(); + switch (ch) { + case KEY_LEFT: case 'h': sel_yes = true; break; + case KEY_RIGHT: case 'l': sel_yes = false; break; + case KEY_DOWN: case 'j': sel_yes = false; break; + case KEY_UP: case 'k': sel_yes = true; break; + case 'y': case 'Y': need_full_redraw_ = true; return true; + case 'n': case 'N': need_full_redraw_ = true; return false; + case '\n': case '\r': case KEY_ENTER: + need_full_redraw_ = true; + return sel_yes; + case 27: { + nodelay(stdscr, TRUE); + int n = getch(); + nodelay(stdscr, FALSE); + if (n == ERR) { need_full_redraw_ = true; return false; } + if (n == '[') { + int m = getch(); + if (m == 'D' || m == 'C') { sel_yes = !sel_yes; break; } + } + need_full_redraw_ = true; + return false; + } + default: break; + } + } +} + +std::string FileManager::overlay_picker(const std::string &title, + const std::vector &items, + bool allow_cancel) { + if (items.empty()) return ""; + + int nitems = static_cast(items.size()); + int bw = cols_ * 2 / 3; + if (bw > 70) bw = 70; + if (bw < 30) bw = 30; + + int max_vis = rows_ - 6; + int vis = std::min(nitems, max_vis); + if (vis < 1) vis = 1; + + int bh = vis + 4; // top + title + divider + vis items + bottom + int bx = (cols_ - bw) / 2; + int by = (rows_ - bh) / 2; + if (bx < 0) bx = 0; + if (by < 1) by = 1; + + int iw = bw - 2; + + int psel = 0, poff = 0; + curs_set(0); + + while (true) { + if (psel < 0) psel = 0; + if (psel >= nitems) psel = nitems - 1; + if (psel < poff) poff = psel; + if (psel >= poff + vis) poff = psel - vis + 1; + if (poff < 0) poff = 0; + + // Clear overlay area + for (int r = 0; r < bh; ++r) + for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' '); + + // ACS borders + attron(ATTR_OVERLAY_BORDER); + mvaddch(by, bx, ACS_ULCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE); + mvaddch(by, bx + bw - 1, ACS_URCORNER); + + mvaddch(by + 1, bx, ACS_VLINE); + mvaddch(by + 1, bx + bw - 1, ACS_VLINE); + + mvaddch(by + 2, bx, ACS_LTEE); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE); + mvaddch(by + 2, bx + bw - 1, ACS_RTEE); + + for (int i = 0; i < vis; ++i) { + mvaddch(by + 3 + i, bx, ACS_VLINE); + mvaddch(by + 3 + i, bx + bw - 1, ACS_VLINE); + } + + mvaddch(by + bh - 1, bx, ACS_LLCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE); + mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER); + attroff(ATTR_OVERLAY_BORDER); + + // Title + attron(ATTR_NORMAL); + mvprintw(by + 1, bx + 1, " %-*s ", iw - 2, title.c_str()); + attroff(ATTR_NORMAL); + + // Items + attron(ATTR_NORMAL); + for (int i = 0; i < vis; ++i) { + int idx = poff + i; + if (idx < nitems) { + std::string label = " " + std::to_string(idx + 1) + " " + items[idx]; + int lw = static_cast(label.size()); + if (lw > iw - 1) label = label.substr(0, iw - 2) + "~"; + int pad_r = iw - 1 - static_cast(label.size()); + if (pad_r < 0) pad_r = 0; + + mvaddch(by + 3 + i, bx + 1, ' '); + if (idx == psel) { + attron(A_REVERSE); + addstr(label.c_str()); + for (int p = 0; p < pad_r; ++p) addch(' '); + attroff(A_REVERSE); + } else { + addstr(label.c_str()); + for (int p = 0; p < pad_r; ++p) addch(' '); + } + } else { + for (int p = 0; p < iw; ++p) mvaddch(by + 3 + i, bx + 1 + p, ' '); + } + } + attroff(ATTR_NORMAL); + + refresh(); + + int ch = getch(); + switch (ch) { + case 'j': case KEY_DOWN: psel++; break; + case 'k': case KEY_UP: psel--; break; + case 'g': psel = 0; break; + case 'G': psel = nitems - 1; break; + case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT: + return items[psel]; + case 'q': case 'Q': case 'h': + if (allow_cancel) { need_full_redraw_ = true; return ""; } + break; + case 27: { + nodelay(stdscr, TRUE); + int n = getch(); + nodelay(stdscr, FALSE); + if (n == ERR) { need_full_redraw_ = true; return ""; } + if (n == '[') { + int m = getch(); + if (m == 'A') psel--; + else if (m == 'B') psel++; + else if (m == 'D') { need_full_redraw_ = true; return ""; } + else { need_full_redraw_ = true; return ""; } + } + break; + } + default: break; + } + } +} + +// ─── line input ───────────────────────────────────────────────────────────── +bool FileManager::read_line(std::string &out, const std::string &prompt, + const std::string &initial) { + out = initial; + int pos = static_cast(out.size()); + int bot = rows_ - 1; + + leaveok(stdscr, FALSE); // need real cursor positioning for editing + curs_set(1); + + while (true) { + // Clear the entire bottom line + move(bot, 0); + clrtoeol(); + + // Prompt + attron(ATTR_INFO); + mvprintw(bot, 0, "%s", prompt.c_str()); + attroff(ATTR_INFO); + + // Input text + pad to end of line + attron(ATTR_NORMAL); + addstr(out.c_str()); + int used = static_cast(prompt.size() + out.size()); + for (int i = used; i < cols_; ++i) addch(' '); + attroff(ATTR_NORMAL); + + move(bot, static_cast(prompt.size() + pos)); + refresh(); + + int ch = getch(); + if (ch == 27) { // ESC + nodelay(stdscr, TRUE); + int n = getch(); + nodelay(stdscr, FALSE); + if (n == ERR) { + leaveok(stdscr, TRUE); + curs_set(0); + out.clear(); + return false; + } + continue; + } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + leaveok(stdscr, TRUE); + curs_set(0); + return true; + } else if (ch == KEY_LEFT) { + if (pos > 0) pos--; + } else if (ch == KEY_RIGHT) { + if (pos < static_cast(out.size())) pos++; + } else if (ch == KEY_HOME || ch == 1) { + pos = 0; + } else if (ch == KEY_END || ch == 5) { + pos = static_cast(out.size()); + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { + if (pos > 0) { + out.erase(pos - 1, 1); + pos--; + } + } else if (ch == KEY_DC) { + if (pos < static_cast(out.size())) + out.erase(pos, 1); + } else if (ch >= 32 && ch < 127) { + out.insert(pos, 1, static_cast(ch)); + pos++; + } + } +} + +// ─── actions ──────────────────────────────────────────────────────────────── +void FileManager::change_dir(const std::string &path) { + prev_cwd_ = cwd_; + last_child_.clear(); + + std::error_code ec; + std::string resolved = fs::canonical(path, ec); + if (ec) { + flash_msg("cannot access directory"); + return; + } + cwd_ = resolved; + filter_.clear(); + selected_.clear(); + sel_ = 0; offset_ = 0; + load_entries(); +} + +void FileManager::do_open() { + const Entry *e = get_sel(); + if (!e) return; + + std::string target = join_path(cwd_, e->name); + // Strip trailing / for directory and @ for symlink + if (!target.empty() && (target.back() == '/' || target.back() == '@')) + target.pop_back(); + + switch (e->type) { + case EntryType::Directory: { + if (access(target.c_str(), R_OK | X_OK) != 0) { + flash_msg("permission denied: " + e->name); + return; + } + prev_cwd_ = cwd_; + last_child_ = e->name; // includes trailing / + cwd_ = fs::canonical(target); + filter_.clear(); + selected_.clear(); + sel_ = 0; offset_ = 0; + load_entries(); + break; + } + case EntryType::Symlink: { + // If symlink points to dir, enter it; otherwise open + struct stat st; + if (stat(target.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) { + if (access(target.c_str(), R_OK | X_OK) != 0) { + flash_msg("permission denied: " + e->name); + return; + } + prev_cwd_ = cwd_; + last_child_.clear(); + cwd_ = fs::canonical(target); + filter_.clear(); + selected_.clear(); + sel_ = 0; offset_ = 0; + load_entries(); + } else { + restore_term(); + open_file(target); + setup_term(); + need_full_redraw_ = true; + } + break; + } + case EntryType::File: { + if (access(target.c_str(), R_OK) != 0) { + flash_msg("permission denied: " + e->name); + return; + } + restore_term(); + open_file(target); + setup_term(); + need_full_redraw_ = true; + break; + } + } +} + +void FileManager::do_go_back() { + if (cwd_ == "/") return; + filter_.clear(); + selected_.clear(); + prev_cwd_ = cwd_; + last_child_ = fs::path(cwd_).filename().string() + "/"; + cwd_ = fs::path(cwd_).parent_path(); + sel_ = 0; offset_ = 0; + load_entries(); + // Restore selection to the dir we came from + if (!last_child_.empty()) { + int idx = find_entry_idx(last_child_); + if (idx >= 0) sel_ = idx; + } + last_child_.clear(); +} + +void FileManager::do_go_home() { + change_dir(home_dir()); +} + +void FileManager::do_jump_back() { + if (prev_cwd_.empty()) { + flash_msg("no previous directory"); + return; + } + std::string tmp = cwd_; + cwd_ = prev_cwd_; + prev_cwd_ = tmp; + filter_.clear(); + selected_.clear(); + sel_ = 0; offset_ = 0; + load_entries(); +} + +void FileManager::do_jump_path() { + std::string path; + if (!read_line(path, " jump to: ")) { need_full_redraw_ = true; return; } + if (path.empty()) { need_full_redraw_ = true; return; } + // Expand ~ + if (path[0] == '~') { + path = home_dir() + path.substr(1); + } + std::error_code ec; + std::string resolved = fs::canonical(path, ec); + if (ec || !fs::is_directory(resolved)) { + flash_msg("not found: " + path); + } else { + change_dir(resolved); + } +} + +void FileManager::do_search() { + searching_ = true; + filter_.clear(); + curs_set(1); + + while (searching_) { + apply_filter(); + sel_ = 0; offset_ = 0; + need_full_redraw_ = true; + draw(); + + int ch = getch(); + if (ch == 27) { // ESC + nodelay(stdscr, TRUE); + int n = getch(); + nodelay(stdscr, FALSE); + if (n == ERR) { + // Bare ESC — clear filter and exit search + filter_.clear(); + searching_ = false; + apply_filter(); + curs_set(0); + need_full_redraw_ = true; + } + // else: arrow key or other sequence — ignore + } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + searching_ = false; + curs_set(0); + need_full_redraw_ = true; + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { + if (!filter_.empty()) filter_.pop_back(); + } else if (ch >= 32 && ch < 127) { + filter_.push_back(static_cast(ch)); + } + } + draw(); +} + +void FileManager::do_clear_filter() { + filter_.clear(); + searching_ = false; + apply_filter(); + sel_ = 0; offset_ = 0; + need_full_redraw_ = true; +} + +void FileManager::do_toggle_hidden() { + show_hidden_ = !show_hidden_; + flash_msg(show_hidden_ ? "hidden files shown" : "hidden files hidden"); + sel_ = 0; offset_ = 0; + load_entries(); +} + +void FileManager::do_toggle_details() { + show_details_ = !show_details_; + flash_msg(show_details_ ? "details on" : "details off"); + need_full_redraw_ = true; +} + +void FileManager::do_toggle_preview() { + show_preview_ = !show_preview_; + flash_msg(show_preview_ ? "preview on" : "preview off"); + need_full_redraw_ = true; +} + +void FileManager::do_sort() { + switch (sort_mode_) { + case SortMode::Name: sort_mode_ = SortMode::Size; flash_msg("sort: size"); break; + case SortMode::Size: sort_mode_ = SortMode::Date; flash_msg("sort: date"); break; + case SortMode::Date: sort_mode_ = SortMode::Name; flash_msg("sort: name"); break; + } + sel_ = 0; offset_ = 0; + sort_entries(); + apply_filter(); + need_full_redraw_ = true; +} + +void FileManager::do_info() { + const Entry *e = get_sel(); + if (!e) return; + + const char *type_str = "file"; + if (e->type == EntryType::Directory) type_str = "directory"; + else if (e->type == EntryType::Symlink) type_str = "symlink"; + else if (e->is_executable) type_str = "executable"; + + // Build info lines + std::vector lines; + lines.push_back("Name: " + e->name); + lines.push_back("Type: " + std::string(type_str)); + lines.push_back("Perm: " + e->permissions); + lines.push_back("Owner: " + e->owner + ":" + e->group); + + double sz; + if (e->type == EntryType::Directory) { + uintmax_t total = 0; + std::error_code ec; + for (const auto &de : fs::recursive_directory_iterator( + e->full_path, fs::directory_options::skip_permission_denied, ec)) { + if (ec) break; + if (de.is_regular_file(ec) && !ec) { + auto s = de.file_size(ec); + if (!ec) total += s; + } + } + sz = static_cast(total); + } else { + sz = static_cast(e->size); + } + const char *u = "B"; + if (sz >= 1024) { sz /= 1024; u = "K"; } + if (sz >= 1024) { sz /= 1024; u = "M"; } + if (sz >= 1024) { sz /= 1024; u = "G"; } + char size_buf[32]; + std::snprintf(size_buf, sizeof(size_buf), "%.0f %s", sz, u); + lines.push_back("Size: " + std::string(size_buf)); + + struct tm tm_val; + localtime_r(&e->mtime.tv_sec, &tm_val); + char date_buf[64]; + std::strftime(date_buf, sizeof(date_buf), "%Y-%m-%d %H:%M:%S", &tm_val); + lines.push_back("Mod: " + std::string(date_buf)); + + if (e->type == EntryType::Symlink) { + lines.push_back("Target: " + e->symlink_target); + } + + // Measure max line width + int maxw = 0; + for (const auto &l : lines) + if (static_cast(l.size()) > maxw) maxw = l.size(); + + int nlines = static_cast(lines.size()); + int bw = maxw + 4; + if (bw > cols_ - 4) bw = cols_ - 4; + int bh = nlines + 4; // top + title + divider + nlines + bottom + int bx = (cols_ - bw) / 2; + int by = (rows_ - bh) / 2; + if (bx < 0) bx = 0; + if (by < 1) by = 1; + int iw = bw - 2; + + // Clear overlay area + for (int r = 0; r < bh; ++r) + for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' '); + + // ACS borders + attron(ATTR_OVERLAY_BORDER); + mvaddch(by, bx, ACS_ULCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE); + mvaddch(by, bx + bw - 1, ACS_URCORNER); + + mvaddch(by + 1, bx, ACS_VLINE); + mvaddch(by + 1, bx + bw - 1, ACS_VLINE); + + mvaddch(by + 2, bx, ACS_LTEE); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + 2, bx + i, ACS_HLINE); + mvaddch(by + 2, bx + bw - 1, ACS_RTEE); + + for (int i = 0; i < nlines; ++i) { + mvaddch(by + 3 + i, bx, ACS_VLINE); + mvaddch(by + 3 + i, bx + bw - 1, ACS_VLINE); + } + + mvaddch(by + bh - 1, bx, ACS_LLCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE); + mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER); + attroff(ATTR_OVERLAY_BORDER); + + // Title + info content + attron(ATTR_NORMAL); + mvprintw(by + 1, bx + 1, " FILE INFO %-*s", iw - 12, ""); + for (int i = 0; i < nlines; ++i) + mvprintw(by + 3 + i, bx + 1, " %-*s ", iw - 2, lines[i].c_str()); + attroff(ATTR_NORMAL); + + refresh(); + + // Wait for any key + nodelay(stdscr, FALSE); + int ch = getch(); + if (ch == 27) { + nodelay(stdscr, TRUE); + getch(); + nodelay(stdscr, FALSE); + } + need_full_redraw_ = true; +} + +void FileManager::do_help() { + int bw = cols_ - 4; + if (bw > 52) bw = 52; + if (bw < 36) bw = 36; + + struct HelpLine { const char *text; bool sep; bool centered; }; + static const HelpLine help[] = { + {"", false, false}, + {"KEYBOARD SHORTCUTS", false, true}, + {"", true, false}, + {" j/k up/down g/G top/bottom", false, false}, + {" h/left go back", false, false}, + {" l/right/enter open / enter dir", false, false}, + {"", true, false}, + {" / search filter", false, false}, + {" esc clear filter / cancel", false, false}, + {" . toggle hidden files", false, false}, + {" i file info in status bar", false, false}, + {" s cycle sort: name/size/date", false, false}, + {" T toggle size/date details", false, false}, + {" P toggle preview pane", false, false}, + {"", true, false}, + {" space toggle multi-select", false, false}, + {" a select all / deselect all", false, false}, + {" y yank/copy (works on selection)", false, false}, + {" x cut (works on selection)", false, false}, + {" p paste", false, false}, + {" d delete (works on selection)", false, false}, + {"", true, false}, + {" r rename R refresh", false, false}, + {" m make directory", false, false}, + {" n new file", false, false}, + {" u trash file (safe delete)", false, false}, + {" U open trash directory", false, false}, + {" ! drop to shell in CWD", false, false}, + {" o open with custom program", false, false}, + {" + chmod +x (make executable)", false, false}, + {" - chmod -x (remove executable)", false, false}, + {"", true, false}, + {" b bookmark current dir", false, false}, + {" B open bookmark picker", false, false}, + {" c copy path to clipboard", false, false}, + {" ~ go to home directory", false, false}, + {" ` jump to previous directory", false, false}, + {" : jump to path", false, false}, + {" f find files recursively", false, false}, + {"", true, false}, + {" q quit", false, false}, + {" ? this help", false, false}, + {"", false, false}, + {" press any key to close...", false, false}, + }; + const int nhelp = sizeof(help) / sizeof(help[0]); + + int bh = nhelp + 2; // top border + nhelp rows + bottom border + if (bh > rows_) bh = rows_; + int bx = (cols_ - bw) / 2; + int by = (rows_ - bh) / 2; + if (bx < 0) bx = 0; + if (by < 1) by = 1; + int iw = bw - 2; + + // Clear overlay area + for (int r = 0; r < bh; ++r) + for (int c = 0; c < bw; ++c) mvaddch(by + r, bx + c, ' '); + + // ACS borders + attron(ATTR_OVERLAY_BORDER); + mvaddch(by, bx, ACS_ULCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by, bx + i, ACS_HLINE); + mvaddch(by, bx + bw - 1, ACS_URCORNER); + + for (int i = 1; i < bh - 1; ++i) { + mvaddch(by + i, bx, ACS_VLINE); + mvaddch(by + i, bx + bw - 1, ACS_VLINE); + } + + mvaddch(by + bh - 1, bx, ACS_LLCORNER); + for (int i = 1; i < bw - 1; ++i) mvaddch(by + bh - 1, bx + i, ACS_HLINE); + mvaddch(by + bh - 1, bx + bw - 1, ACS_LRCORNER); + attroff(ATTR_OVERLAY_BORDER); + + // Help content + int visible = bh - 2; + for (int i = 0; i < nhelp && i < visible; ++i) { + if (help[i].sep) { + attron(ATTR_OVERLAY_BORDER); + mvaddch(by + 1 + i, bx, ACS_LTEE); + for (int c = 1; c < bw - 1; ++c) mvaddch(by + 1 + i, bx + c, ACS_HLINE); + mvaddch(by + 1 + i, bx + bw - 1, ACS_RTEE); + attroff(ATTR_OVERLAY_BORDER); + } else { + attron(ATTR_NORMAL); + int w = static_cast(std::strlen(help[i].text)); + if (help[i].centered) { + int lpad = (iw - w) / 2; + if (lpad < 0) lpad = 0; + for (int p = 0; p < lpad; ++p) mvaddch(by + 1 + i, bx + 1 + p, ' '); + mvaddstr(by + 1 + i, bx + 1 + lpad, help[i].text); + int rpad = iw - w - lpad; + for (int p = 0; p < rpad; ++p) mvaddch(by + 1 + i, bx + 1 + lpad + w + p, ' '); + } else { + mvaddstr(by + 1 + i, bx + 1, help[i].text); + int pad = iw - w; + for (int p = 0; p < pad; ++p) mvaddch(by + 1 + i, bx + 1 + w + p, ' '); + } + attroff(ATTR_NORMAL); + } + } + + refresh(); + + // Wait for any key + nodelay(stdscr, FALSE); + int ch = getch(); + if (ch == 27) { + nodelay(stdscr, TRUE); + getch(); + nodelay(stdscr, FALSE); + } + need_full_redraw_ = true; +} + +void FileManager::do_rename() { + const Entry *e = get_sel(); + if (!e) return; + // Strip trailing / or @ + std::string name = e->name; + if (!name.empty() && (name.back() == '/' || name.back() == '@')) + name.pop_back(); + + std::string new_name; + if (!read_line(new_name, " Rename: ", name)) { need_full_redraw_ = true; return; } + if (new_name.empty() || new_name == name) { flash_msg("cancelled"); return; } + + std::string src = join_path(cwd_, name); + std::string dst = join_path(cwd_, new_name); + if (std::rename(src.c_str(), dst.c_str()) == 0) { + load_entries(); + } else { + flash_msg("rename failed"); + } +} + +void FileManager::do_mkdir() { + std::string name; + if (!read_line(name, " Directory name: ")) { need_full_redraw_ = true; return; } + if (name.empty()) { flash_msg("cancelled"); return; } + + std::string path = join_path(cwd_, name); + if (fs::create_directory(path)) { + load_entries(); + } else { + flash_msg("mkdir failed"); + } +} + +void FileManager::do_newfile() { + std::string name; + if (!read_line(name, " File name: ")) { need_full_redraw_ = true; return; } + if (name.empty()) { flash_msg("cancelled"); return; } + + std::string path = join_path(cwd_, name); + std::ofstream f(path); + if (f) { + f.close(); + load_entries(); + } else { + flash_msg("cannot create file"); + } +} + +void FileManager::do_delete() { + // Gather items to delete + std::vector targets; + if (!selected_.empty()) { + for (const auto &sel_name : selected_) + targets.push_back(join_path(cwd_, strip_suffix(strip_suffix(sel_name, "@"), "/"))); + } else { + const Entry *e = get_sel(); + if (!e) return; + std::string n = e->name; + if (!n.empty() && n.back() == '/') n.pop_back(); + if (!n.empty() && n.back() == '@') n.pop_back(); + targets.push_back(join_path(cwd_, n)); + } + + if (targets.empty()) return; + + // Confirm + std::string prompt; + if (targets.size() == 1) { + prompt = "Delete \"" + fs::path(targets[0]).filename().string() + "\"?"; + } else { + prompt = "Delete " + std::to_string(targets.size()) + " selected items?"; + } + + if (!confirm_overlay(prompt)) { need_full_redraw_ = true; return; } + + // For single non-empty dir, second confirmation + if (targets.size() == 1) { + struct stat st; + if (stat(targets[0].c_str(), &st) == 0 && S_ISDIR(st.st_mode)) { + // Check if non-empty + DIR *d = opendir(targets[0].c_str()); + if (d) { + bool empty = true; + struct dirent *de; + while ((de = readdir(d))) { + std::string dn(de->d_name); + if (dn != "." && dn != "..") { empty = false; break; } + } + closedir(d); + if (!empty) { + std::string prompt2 = "\"" + fs::path(targets[0]).filename().string() + + "\" not empty. Delete ALL?"; + if (!confirm_overlay(prompt2)) { need_full_redraw_ = true; return; } + } + } + } + } + + // Execute + std::error_code ec; + for (const auto &t : targets) { + fs::remove_all(t, ec); + } + selected_.clear(); + if (sel_ >= static_cast(entries_.size()) - 1) + sel_ = std::max(0, static_cast(entries_.size()) - 2); + if (sel_ < 0) sel_ = 0; + load_entries(); +} + +void FileManager::do_trash() { + const Entry *e = get_sel(); + if (!e) return; + std::string name = e->name; + if (!name.empty() && name.back() == '/') name.pop_back(); + if (!name.empty() && name.back() == '@') name.pop_back(); + + std::string src = join_path(cwd_, name); + + // Timestamp prefix + time_t now = time(nullptr); + struct tm tm_val; + localtime_r(&now, &tm_val); + char ts[20]; + std::strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", &tm_val); + + std::string dst = trash_dir_ + "/" + ts + "_" + name; + + // Use copy + remove to handle cross-device moves + bool ok = false; + if (std::rename(src.c_str(), dst.c_str()) == 0) { + ok = true; + } else if (errno == EXDEV) { + // Cross-device: copy then delete + std::error_code ec; + fs::copy(src, dst, fs::copy_options::recursive, ec); + if (!ec) { + fs::remove_all(src, ec); + ok = !ec; + } + } + if (ok) { + flash_msg("trashed: " + e->name); + if (sel_ >= static_cast(entries_.size()) - 1) + sel_ = std::max(0, static_cast(entries_.size()) - 2); + load_entries(); + } else { + flash_msg("trash failed"); + } +} + +void FileManager::do_open_trash() { + change_dir(trash_dir_); +} + +void FileManager::do_open_with() { + const Entry *e = get_sel(); + if (!e || e->type == EntryType::Directory) { + flash_msg("cannot open-with a directory"); + return; + } + std::string name = e->name; + if (!name.empty() && name.back() == '@') name.pop_back(); + std::string target = join_path(cwd_, name); + + std::string prog; + if (!read_line(prog, " Open \"" + e->name + "\" with: ")) { + need_full_redraw_ = true; return; + } + if (prog.empty()) { flash_msg("cancelled"); return; } + + // Check program exists + std::string which_cmd = "command -v " + shell_escape(prog) + " >/dev/null 2>&1"; + if (system(which_cmd.c_str()) != 0) { + flash_msg("not found: " + prog); + return; + } + + restore_term(); + std::string cmd = shell_escape(prog) + " " + shell_escape(target); + system(cmd.c_str()); + setup_term(); + need_full_redraw_ = true; +} + +void FileManager::do_chmod_x(bool set) { + const Entry *e = get_sel(); + if (!e || e->type == EntryType::Directory) { + flash_msg("cannot chmod a directory"); + return; + } + std::string name = e->name; + if (!name.empty() && name.back() == '@') name.pop_back(); + std::string target = join_path(cwd_, name); + + struct stat st; + if (stat(target.c_str(), &st) != 0) { + flash_msg("chmod failed"); + return; + } + + mode_t mode = st.st_mode; + if (set) mode |= (S_IXUSR | S_IXGRP | S_IXOTH); + else mode &= ~(S_IXUSR | S_IXGRP | S_IXOTH); + + if (chmod(target.c_str(), mode) == 0) { + flash_msg(set ? "chmod +x: " + e->name : "chmod -x: " + e->name); + load_entries(); + } else { + flash_msg("chmod failed"); + } +} + +void FileManager::do_find() { + std::string query; + if (!read_line(query, " find (recursive): ")) { need_full_redraw_ = true; return; } + if (query.empty()) { need_full_redraw_ = true; return; } + + std::vector results; + std::string lq = lower(query); + + try { + for (const auto &entry : + fs::recursive_directory_iterator(cwd_, + fs::directory_options::skip_permission_denied)) { + std::string fname = entry.path().filename().string(); + if (lower(fname).find(lq) != std::string::npos) + results.push_back(entry.path().string()); + } + } catch (const fs::filesystem_error &) { + // Skip errors + } + + if (results.empty()) { + flash_msg("no results for: " + query); + return; + } + + std::sort(results.begin(), results.end()); + + // Show picker + std::string chosen = overlay_picker(" find: " + query + " (" + + std::to_string(results.size()) + " results)", + results); + if (chosen.empty()) { need_full_redraw_ = true; return; } + + if (fs::is_directory(chosen)) { + change_dir(chosen); + } else { + std::string dir = fs::path(chosen).parent_path(); + std::string fname = fs::path(chosen).filename().string(); + change_dir(dir); + // Try to highlight the file + int idx = find_entry_idx(fname); + if (idx < 0) { + // Maybe it's a directory + idx = find_entry_idx(fname + "/"); + } + if (idx >= 0) sel_ = idx; + } +} + +void FileManager::do_bookmark_add() { + // Read existing bookmarks + std::vector bookmarks; + std::ifstream in(bookmark_file_); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line[0] != '#') bookmarks.push_back(line); + } + in.close(); + + // Check if already bookmarked + auto it = std::find(bookmarks.begin(), bookmarks.end(), cwd_); + if (it != bookmarks.end()) { + bookmarks.erase(it); + std::ofstream out(bookmark_file_); + for (const auto &b : bookmarks) out << b << '\n'; + flash_msg("bookmark removed: " + cwd_); + } else { + std::ofstream out(bookmark_file_, std::ios::app); + out << cwd_ << '\n'; + flash_msg("bookmarked: " + cwd_); + } + need_full_redraw_ = true; +} + +void FileManager::do_bookmark_jump() { + std::vector bookmarks; + std::ifstream in(bookmark_file_); + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line[0] != '#') bookmarks.push_back(line); + } + if (bookmarks.empty()) { + flash_msg("no bookmarks saved"); + return; + } + + std::string chosen = overlay_picker(" BOOKMARKS", bookmarks); + if (chosen.empty()) { need_full_redraw_ = true; return; } + + if (fs::is_directory(chosen)) { + change_dir(chosen); + } else { + flash_msg("not found: " + chosen); + need_full_redraw_ = true; + } +} + +void FileManager::do_copy_path() { + const Entry *e = get_sel(); + if (!e) return; + std::string name = e->name; + if (!name.empty() && name.back() == '/') name.pop_back(); + if (!name.empty() && name.back() == '@') name.pop_back(); + std::string path = join_path(cwd_, name); + + // Try clipboard tools + const char *tools[] = {"wl-copy", "xclip", "xsel", "pbcopy", nullptr}; + bool ok = false; + for (int i = 0; tools[i]; ++i) { + std::string cmd = std::string("command -v ") + tools[i] + " >/dev/null 2>&1"; + if (system(cmd.c_str()) == 0) { + std::string pipe_cmd; + if (std::string(tools[i]) == "xclip") + pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | xclip -selection clipboard"; + else if (std::string(tools[i]) == "xsel") + pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | xsel --clipboard --input"; + else + pipe_cmd = std::string("printf '%s' ") + shell_escape(path) + " | " + tools[i]; + ok = (system(pipe_cmd.c_str()) == 0); + break; + } + } + if (ok) + flash_msg("path copied: " + path); + else + flash_msg("no clipboard tool found"); +} + +void FileManager::do_toggle_select() { + const Entry *e = get_sel(); + if (!e) return; + if (selected_.count(e->name)) { + selected_.erase(e->name); + } else { + selected_.insert(e->name); + } + auto sc = selected_.size(); + flash_msg(sc == 0 ? "selection cleared" : std::to_string(sc) + " selected"); + sel_++; + if (sel_ >= static_cast(entries_.size())) sel_ = std::max(0, static_cast(entries_.size()) - 1); + need_full_redraw_ = true; +} + +void FileManager::do_select_all() { + if (!selected_.empty()) { + selected_.clear(); + flash_msg("selection cleared"); + } else { + for (const auto &e : entries_) { + if (e.name != "../") selected_.insert(e.name); + } + flash_msg(std::to_string(selected_.size()) + " items"); + } + need_full_redraw_ = true; +} + +void FileManager::do_yank() { + clipboard_paths_.clear(); + if (!selected_.empty()) { + for (const auto &sel_name : selected_) { + std::string n = sel_name; + if (!n.empty() && n.back() == '/') n.pop_back(); + if (!n.empty() && n.back() == '@') n.pop_back(); + clipboard_paths_.push_back(join_path(cwd_, n)); + } + clip_mode_ = ClipMode::Copy; + flash_msg("yanked " + std::to_string(clipboard_paths_.size()) + " items"); + selected_.clear(); + } else { + const Entry *e = get_sel(); + if (!e) return; + std::string n = e->name; + if (!n.empty() && n.back() == '/') n.pop_back(); + if (!n.empty() && n.back() == '@') n.pop_back(); + clipboard_paths_.push_back(join_path(cwd_, n)); + clip_mode_ = ClipMode::Copy; + flash_msg("yanked: " + e->name); + } + need_full_redraw_ = true; +} + +void FileManager::do_cut() { + clipboard_paths_.clear(); + if (!selected_.empty()) { + for (const auto &sel_name : selected_) { + std::string n = sel_name; + if (!n.empty() && n.back() == '/') n.pop_back(); + if (!n.empty() && n.back() == '@') n.pop_back(); + clipboard_paths_.push_back(join_path(cwd_, n)); + } + clip_mode_ = ClipMode::Cut; + flash_msg("cut " + std::to_string(clipboard_paths_.size()) + " items"); + selected_.clear(); + } else { + const Entry *e = get_sel(); + if (!e) return; + std::string n = e->name; + if (!n.empty() && n.back() == '/') n.pop_back(); + if (!n.empty() && n.back() == '@') n.pop_back(); + clipboard_paths_.push_back(join_path(cwd_, n)); + clip_mode_ = ClipMode::Cut; + flash_msg("cut: " + e->name); + } + need_full_redraw_ = true; +} + +void FileManager::do_paste() { + if (clipboard_paths_.empty() || clip_mode_ == ClipMode::None) { + flash_msg("nothing to paste"); + return; + } + + for (const auto &src : clipboard_paths_) { + std::string name = fs::path(src).filename().string(); + std::string dst = join_path(cwd_, name); + + // Auto-rename on collision + if (fs::exists(dst)) { + std::string stem = fs::path(name).stem().string(); + std::string ext = fs::path(name).extension().string(); + + // First try _copy + std::string try_name = stem + "_copy" + ext; + std::string try_dst = join_path(cwd_, try_name); + if (fs::exists(try_dst)) { + // Then try _copy2, _copy3, ... + int n = 2; + do { + try_name = stem + "_copy" + std::to_string(n) + ext; + try_dst = join_path(cwd_, try_name); + n++; + } while (fs::exists(try_dst) && n < 1000); + } + dst = try_dst; + } + + std::error_code ec; + if (clip_mode_ == ClipMode::Copy) { + fs::copy(src, dst, fs::copy_options::recursive, ec); + } else { + fs::rename(src, dst, ec); + if (ec && ec.value() == EXDEV) { + // Cross-device: copy then delete + std::error_code ec2; + fs::copy(src, dst, fs::copy_options::recursive, ec2); + if (!ec2) { + fs::remove_all(src, ec2); + if (!ec2) ec.clear(); + } + } + } + if (ec) { + flash_msg("paste error: " + ec.message()); + return; + } + } + + clipboard_paths_.clear(); + clip_mode_ = ClipMode::None; + flash_msg("pasted"); + load_entries(); +} + +void FileManager::do_shell() { + restore_term(); + std::printf("\033[2J\033[H"); + if (chdir(cwd_.c_str()) != 0) { + std::fprintf(stderr, "sfm: cannot cd to %s\n", cwd_.c_str()); + } + std::printf("type \"exit\" to return to sfm\n"); + + const char *sh = getenv("SHELL"); + if (!sh || !*sh) sh = "/bin/sh"; + + pid_t pid = fork(); + if (pid == 0) { + execl(sh, sh, nullptr); + _exit(127); + } + int status; + waitpid(pid, &status, 0); + + // Restore CWD in case user cd'd around + char buf[PATH_MAX]; + if (getcwd(buf, sizeof(buf))) cwd_ = buf; + + setup_term(); + need_full_redraw_ = true; +} + +// ─── smart file opener ────────────────────────────────────────────────────── +bool FileManager::try_run(const std::vector &progs, + const std::string &path, bool gui) { + for (auto prog : progs) { + std::string which = std::string("command -v ") + prog + " >/dev/null 2>&1"; + if (system(which.c_str()) == 0) { + pid_t pid = fork(); + if (pid == 0) { + if (gui) { + setsid(); + int dn = open("/dev/null", O_RDWR); + if (dn >= 0) { dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); if (dn > 2) close(dn); } + } + execlp(prog, prog, path.c_str(), nullptr); + _exit(127); + } + if (!gui) { + int status; + waitpid(pid, &status, 0); + } + return true; + } + } + return false; +} + +void FileManager::open_file(const std::string &path) { + // 1. Custom opener script + if (access(opener_path_.c_str(), X_OK) == 0) { + std::string cmd = shell_escape(opener_path_) + " " + shell_escape(path); + system(cmd.c_str()); + return; + } + + std::string ext = file_ext(path); + + // 2. Text / code → $EDITOR or vi (terminal) + { + static const std::vector text_exts = { + "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" + }; + if (std::find(text_exts.begin(), text_exts.end(), ext) != text_exts.end()) { + const char *ed = getenv("EDITOR"); + std::vector editors; + if (ed) editors.push_back(ed); + editors.push_back("vi"); + try_run(editors, path, false); + return; + } + } + + // 3. Images → GUI + { + static const std::vector img_exts = { + "jpg","jpeg","png","gif","bmp","tiff","tif","webp","svg","ico", + "heic","heif","avif" + }; + if (std::find(img_exts.begin(), img_exts.end(), ext) != img_exts.end()) { + if (try_run({"imv","imvr","feh","sxiv","nsxiv","eog","eom","viewnior", + "shotwell","gimp"}, path, true)) + return; + } + } + + // 4. Video → GUI + { + static const std::vector vid_exts = { + "mp4","mkv","avi","mov","wmv","flv","webm","m4v","mpeg","mpg", + "3gp","ogv" + }; + if (std::find(vid_exts.begin(), vid_exts.end(), ext) != vid_exts.end()) { + if (try_run({"mpv","vlc","mplayer","totem","celluloid","haruna"}, path, true)) + return; + } + } + + // 5. Audio → terminal or GUI + { + static const std::vector aud_exts = { + "mp3","flac","ogg","wav","aac","m4a","opus","wma","aiff" + }; + if (std::find(aud_exts.begin(), aud_exts.end(), ext) != aud_exts.end()) { + if (try_run({"mpv","vlc","mplayer","cmus","mocp"}, path, false)) + return; + } + } + + // 6. PDF → GUI + if (ext == "pdf") { + if (try_run({"zathura","evince","okular","mupdf","atril","xreader"}, path, true)) + return; + } + + // 7. Office docs → GUI + { + static const std::vector off_exts = { + "odt","ods","odp","doc","docx","xls","xlsx","ppt","pptx" + }; + if (std::find(off_exts.begin(), off_exts.end(), ext) != off_exts.end()) { + if (try_run({"libreoffice","soffice"}, path, true)) + return; + } + } + + // 8. Archives → list in pager + { + static const std::vector arc_exts = { + "zip","tar","gz","bz2","xz","zst","7z","rar" + }; + if (std::find(arc_exts.begin(), arc_exts.end(), ext) != arc_exts.end()) { + // atool + if (system("command -v atool >/dev/null 2>&1") == 0) { + std::string cmd = "atool -l " + shell_escape(path) + " 2>&1 | ${PAGER:-less}"; + system(cmd.c_str()); + return; + } + // bsdtar + if (system("command -v bsdtar >/dev/null 2>&1") == 0) { + std::string cmd = "bsdtar -tf " + shell_escape(path) + " 2>&1 | ${PAGER:-less}"; + system(cmd.c_str()); + return; + } + } + } + + // 9. MIME fallback via `file` + { + std::string cmd = "file --mime-type -b " + shell_escape(path) + " 2>/dev/null"; + FILE *fp = popen(cmd.c_str(), "r"); + std::string mime; + if (fp) { + char buf[256]; + if (fgets(buf, sizeof(buf), fp)) mime = buf; + // trim newline + if (!mime.empty() && mime.back() == '\n') mime.pop_back(); + pclose(fp); + } + + if (!mime.empty()) { + if (mime.find("text/") == 0 + || mime == "application/json" + || mime == "application/xml" + || mime == "application/javascript") { + const char *ed = getenv("EDITOR"); + std::vector eds; + if (ed) eds.push_back(ed); + eds.push_back("vi"); + try_run(eds, path, false); + return; + } + if (mime.find("image/") == 0) { + try_run({"imv","feh","sxiv","nsxiv","eog","gimp"}, path, true); + return; + } + if (mime.find("video/") == 0) { + try_run({"mpv","vlc","mplayer"}, path, true); + return; + } + if (mime.find("audio/") == 0) { + try_run({"mpv","vlc","mplayer"}, path, false); + return; + } + if (mime == "application/pdf") { + try_run({"zathura","evince","okular","mupdf"}, path, true); + return; + } + } + } + + // 10. Last resort: xdg-open / open / EDITOR + { + if (try_run({"xdg-open"}, path, true)) return; + if (try_run({"open"}, path, true)) return; + const char *ed = getenv("EDITOR"); + std::vector eds; + if (ed) eds.push_back(ed); + eds.push_back("vi"); + try_run(eds, path, false); + } +} + +// ─── main event loop ──────────────────────────────────────────────────────── +void FileManager::run() { + setup_term(); + if (can_color()) init_colors(); + load_entries(); + + while (true) { + draw(); + int key = getch(); + + // Handle terminal resize + if (key == KEY_RESIZE) { + update_size(); + need_full_redraw_ = true; + continue; + } + + switch (key) { + case 'j': case KEY_DOWN: + if (!entries_.empty() && sel_ < static_cast(entries_.size()) - 1) + sel_++; + break; + case 'k': case KEY_UP: + if (sel_ > 0) sel_--; + break; + case 'g': sel_ = 0; break; + case 'G': + if (!entries_.empty()) sel_ = static_cast(entries_.size()) - 1; + break; + case KEY_NPAGE: + sel_ = std::min(sel_ + rows_ / 2, + std::max(0, static_cast(entries_.size()) - 1)); + break; + case KEY_PPAGE: + sel_ = std::max(sel_ - rows_ / 2, 0); + break; + case KEY_DC: do_delete(); break; + case '\n': case '\r': case KEY_ENTER: case 'l': case KEY_RIGHT: + do_open(); break; + case 'h': case KEY_LEFT: + do_go_back(); break; + case 'b': do_bookmark_add(); break; + case 'B': do_bookmark_jump(); break; + case '?': do_help(); break; + case 'R': load_entries(); flash_msg("refreshed"); break; + case '/': do_search(); break; + case '.': do_toggle_hidden(); break; + case 'T': do_toggle_details(); break; + case 'P': do_toggle_preview(); break; + case 'i': do_info(); break; + case '+': do_chmod_x(true); break; + case '-': do_chmod_x(false); break; + case 'o': do_open_with(); break; + case 's': do_sort(); break; + case 'u': do_trash(); break; + case 'U': do_open_trash(); break; + case 'f': do_find(); break; + case ':': do_jump_path(); break; + case '~': do_go_home(); break; + case '`': do_jump_back(); break; + case 'c': do_copy_path(); break; + case 27: do_clear_filter(); break; // ESC key + case ' ': do_toggle_select(); break; + case 'a': do_select_all(); break; + case 'y': do_yank(); break; + case 'x': do_cut(); break; + case 'p': do_paste(); break; + case 'd': do_delete(); break; + case 'r': do_rename(); break; + case 'm': do_mkdir(); break; + case 'n': do_newfile(); break; + case 'q': case 'Q': return; + case '!': do_shell(); break; + default: break; + } + } +} + +// ─── main ─────────────────────────────────────────────────────────────────── +int main(int argc, char *argv[]) { + std::string start_path; + if (argc > 1) start_path = argv[1]; + + FileManager fm(start_path); + fm.run(); + return 0; +} diff --git a/sfm.sh b/sfm.sh new file mode 100755 index 0000000..032b6e5 --- /dev/null +++ b/sfm.sh @@ -0,0 +1,1870 @@ +#!/bin/sh +# sfm - Simple File Manager in POSIX sh + +# --- terminal control --- +tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; } + +RESET=$(tput_cmd sgr0) +BOLD=$(tput_cmd bold) +REV=$(tput_cmd rev) +RED=$(tput_cmd setaf 1) +GREEN=$(tput_cmd setaf 2) +YELLOW=$(tput_cmd setaf 3) +CYAN=$(tput_cmd setaf 6) +WHITE=$(tput_cmd setaf 7) +MAGENTA=$(tput_cmd setaf 5) +BLUE=$(tput_cmd setaf 4) +ERASE_LINE=$(tput_cmd el || printf '\033[K') + +goto() { printf '\033[%d;%dH' "$1" "$2"; } +hide_cursor() { printf '\033[?25l'; } +show_cursor() { printf '\033[?25h'; } +term_rows() { tput_cmd lines || echo 24; } +term_cols() { tput_cmd cols || echo 80; } + +# --- state --- +if [ -n "$1" ]; then + CWD=$(cd "$1" 2>/dev/null && pwd) || { printf 'sfm: %s: no such directory\n' "$1" >&2; exit 1; } +else + CWD=$(cd "$PWD" 2>/dev/null && pwd) || CWD="/" +fi +SEL=0 +OFFSET=0 +PREV_SEL=0 +PREV_OFFSET=0 +NEED_FULL_REDRAW=1 +LAST_CHILD="" # name of dir we came from, used to restore selection on go-back +FILTER="" # current search string; empty = no filter +ALL_ENTRIES="" # unfiltered entries +ALL_COUNT=0 +SEARCHING=0 # 1 while in search input mode +SELECTED="" # newline-separated list of multi-selected entry names +CLIPBOARD="" # full path of yanked/cut entry +CLIP_MODE="" # "copy" or "cut" +INFO_MSG="" # ephemeral message shown in botbar +SHOW_HIDDEN=0 # 1 = show dotfiles +SORT_MODE="name" # name | size | date +SHOW_DETAILS=0 # 1 = show size+date column +SHOW_PREVIEW=0 # 1 = show preview pane on right +TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash" +mkdir -p "$TRASH_DIR" +PREV_CWD="" # last visited directory, for jumping back + +# normalise CWD: resolve to absolute canonical path, no double slashes +norm_cwd() { + CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/" + # squeeze any double slashes (some systems return // for //) + 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" -- cgit v1.2.3