17 Commits
Author SHA1 Message Date
emmett1 0504dec9c7 info mode now shows info box 2026-05-31 00:08:51 +08:00
emmett1 f85d1af121 fix new directory mode text 2026-05-31 00:00:48 +08:00
emmett1 308637d874 fix new file mode text 2026-05-30 23:59:28 +08:00
emmett1 aaf7fd829d improve rename mode 2026-05-30 23:57:36 +08:00
emmett1 8db9a070b7 yes no box for file deletion confirmation 2026-05-30 23:51:26 +08:00
emmett1 bed888c3c3 fix and updates 2026-05-29 11:32:58 +08:00
emmett1 7c2f21d8ec updated man page 2026-04-11 09:05:45 +08:00
emmett1 938955120b fix page down, page up and del key, added del key to delete function 2026-04-11 08:57:44 +08:00
emmett1 f374506215 update 2026-04-11 08:51:01 +08:00
emmett1 fe57971b91 removed extension colouring, and changed some colours 2026-04-11 02:11:23 +08:00
emmett1 2f8d3e8d9e man page updated 2026-04-10 07:35:11 +08:00
emmett1 2a1f8fc0a5 add find function 2026-04-05 20:00:02 +08:00
emmett1 346ef7b7cb add jump to path function 2026-04-05 19:39:24 +08:00
emmett1 09f60a85ef add chmod +x and -x function 2026-04-05 19:33:56 +08:00
emmett1 32337e4cca show hidden files first when enabled hidden 2026-04-05 01:35:44 +08:00
emmett1 03df88a150 added man page 2026-04-01 00:35:31 +08:00
emmett1 54352d036c renamed README.txt to README 2026-04-01 00:35:04 +08:00
4 changed files with 918 additions and 218 deletions
+8 -4
View File
@@ -15,17 +15,21 @@ all:
@echo "Run 'make install' to install."
install:
@echo "Installing sfm to $(BINDIR)/sfm ..."
@echo "Installing sfm to $(DESTDIR)$(BINDIR)/sfm ..."
$(INSTALL) -d $(DESTDIR)$(BINDIR)
$(INSTALL) -m 755 sfm $(DESTDIR)$(BINDIR)/sfm
@echo "Installing README to $(DOCDIR) ..."
@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.txt $(DESTDIR)$(DOCDIR)/README.txt
@echo "Done. Run 'sfm' to start."
$(INSTALL) -m 644 README $(DESTDIR)$(DOCDIR)/README
@echo "Done. Run 'sfm' to start, or 'man sfm' for help."
uninstall:
@echo "Removing sfm ..."
$(RM) $(DESTDIR)$(BINDIR)/sfm
$(RM) $(DESTDIR)$(MANDIR)/sfm.1
$(RM) -r $(DESTDIR)$(DOCDIR)
@echo "Done."
View File
+509 -189
View File
@@ -1,6 +1,5 @@
#!/bin/sh
# sfm - Simple File Manager in POSIX sh (flicker-free)
# version 0.4
# sfm - Simple File Manager in POSIX sh
# --- terminal control ---
tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; }
@@ -17,44 +16,6 @@ MAGENTA=$(tput_cmd setaf 5)
BLUE=$(tput_cmd setaf 4)
ERASE_LINE=$(tput_cmd el || printf '\033[K')
# map a filename to a display colour based on extension
file_colour() {
_fc_name="$1"
_fc_ext="${_fc_name##*.}"
_fc_ext=$(printf '%s' "$_fc_ext" | tr '[:upper:]' '[:lower:]')
# executable?
[ -x "${CWD}/${_fc_name}" ] && { printf '%s' "${RED}${BOLD}"; return; }
case "$_fc_ext" in
# images
jpg|jpeg|png|gif|bmp|tiff|tif|webp|svg|ico|heic|heif|avif)
printf '%s' "${MAGENTA}" ;;
# video
mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpeg|mpg|3gp|ogv)
printf '%s' "${MAGENTA}${BOLD}" ;;
# audio
mp3|flac|ogg|wav|aac|m4a|opus|wma|aiff)
printf '%s' "${CYAN}${BOLD}" ;;
# archives
zip|tar|gz|bz2|xz|zst|7z|rar|tgz|tbz2)
printf '%s' "${RED}" ;;
# documents / text
pdf|doc|docx|odt|xls|xlsx|ods|ppt|pptx|odp)
printf '%s' "${YELLOW}" ;;
# code / scripts
sh|bash|zsh|fish|py|rb|pl|lua|js|ts|jsx|tsx|\
c|h|cpp|cc|cxx|hpp|rs|go|java|kt|swift|cs|php|r)
printf '%s' "${GREEN}" ;;
# data / config
json|xml|yaml|yml|toml|ini|conf|cfg|env)
printf '%s' "${CYAN}" ;;
# markdown / text
md|markdown|rst|txt|log)
printf '%s' "${WHITE}" ;;
# default
*) printf '%s' "${WHITE}" ;;
esac
}
goto() { printf '\033[%d;%dH' "$1" "$2"; }
hide_cursor() { printf '\033[?25l'; }
show_cursor() { printf '\033[?25h'; }
@@ -107,22 +68,11 @@ load_entries() {
ENTRIES=""
COUNT=0
# build list of dir names then file names, including dotfiles if enabled
for d in "$CWD"/*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue # symlinks handled separately
name="${d%/}"; name="${name##*/}"
[ "$name" = "*" ] && continue
case "$name" in .*) continue ;; esac
ENTRIES="$ENTRIES
${name}/"
COUNT=$((COUNT + 1))
done
# hidden dirs first
if [ "$SHOW_HIDDEN" = "1" ]; then
for d in "$CWD"/./; do : ; done
for d in "$CWD"/.*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue # symlinks handled separately
[ -L "${d%/}" ] && continue
name="${d%/}"; name="${name##*/}"
[ "$name" = "." ] || [ "$name" = ".." ] && continue
[ "$name" = ".*" ] && continue
@@ -132,21 +82,19 @@ ${name}/"
done
fi
for f in "$CWD"/*; do
[ -e "$f" ] || [ -L "$f" ] || continue
[ -d "$f" ] && ! [ -L "$f" ] && continue
name="${f##*/}"
# regular dirs
for d in "$CWD"/*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue
name="${d%/}"; name="${name##*/}"
[ "$name" = "*" ] && continue
case "$name" in .*) continue ;; esac
if [ -L "$f" ]; then
ENTRIES="$ENTRIES
${name}@"
else
ENTRIES="$ENTRIES
${name}"
fi
${name}/"
COUNT=$((COUNT + 1))
done
# hidden files first
if [ "$SHOW_HIDDEN" = "1" ]; then
for f in "$CWD"/.*; do
[ -e "$f" ] || [ -L "$f" ] || continue
@@ -164,6 +112,23 @@ ${name}"
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#
}"
@@ -192,8 +157,8 @@ $_line"; fi ;;
# 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 ;;
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=""
@@ -208,7 +173,7 @@ ${_n}
else _sorted="$_sorted
$_n"; fi ;;
esac
done < /tmp/_fm_sorted
done < /tmp/_fm_sorted_$$
_files="$_sorted"
fi
@@ -262,50 +227,47 @@ apply_filter() {
if [ -z "$FILTER" ]; then
ENTRIES="$ALL_ENTRIES"
COUNT="$ALL_COUNT"
return
fi
else
ENTRIES=""
COUNT=0
printf '%s\n' "$ALL_ENTRIES" | while IFS= read -r line; do
case "$line" in
*"$FILTER"*) printf '%s\n' "$line" ;;
esac
done | {
while IFS= read -r line; do
ENTRIES="$ENTRIES
$line"
COUNT=$((COUNT + 1))
done
# write back via temp — subshell can't modify parent vars directly
printf '%s\n' "$COUNT" > /tmp/_fm_count
printf '%s' "$ENTRIES" > /tmp/_fm_entries
}
COUNT=$(cat /tmp/_fm_count 2>/dev/null || echo 0)
ENTRIES=$(cat /tmp/_fm_entries 2>/dev/null || echo "")
ENTRIES="${ENTRIES#
_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() {
printf '%s\n' "$ENTRIES" | awk -v n="$(($1 + 1))" 'NR==n{print; exit}'
sed -n "$(($1 + 1))p" /tmp/_sfm_list_$$
}
# find index of entry by name (0-based), returns -1 if not found
find_entry() {
printf '%s\n' "$ENTRIES" | awk -v name="$1" '
{ if ($0 == name) { print NR-1; found=1; exit } }
END { if (!found) print -1 }
'
_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() {
case "
${SELECTED}
" in *"
$1
"*) return 0 ;; esac
return 1
printf '%s\n' "$SELECTED" | grep -Fxq "$1"
}
count_selected() {
@@ -359,16 +321,22 @@ render_row() {
entry=$(get_entry "$_idx")
case "$entry" in
*/) colour="${GREEN}${BOLD}" ;;
*/) colour="${BLUE}${BOLD}" ;;
*@)
_name="${entry%@}"
# broken symlink if target doesn't exist
if [ -e "${CWD}/${_name}" ]; then
colour="${MAGENTA}${BOLD}"
colour="${CYAN}${BOLD}"
else
colour="${RED}${BOLD}"
fi ;;
*) colour=$(file_colour "$entry") ;;
*)
_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
@@ -400,17 +368,21 @@ render_row() {
if [ "$SHOW_DETAILS" = "1" ]; then
_path="${CWD}/${entry%/}"; _path="${_path%@}"
_info=$(ls -ldh "$_path" 2>/dev/null)
_sz=$(printf '%s' "$_info" | awk '{print $5}')
_dt=$(printf '%s' "$_info" | awk '{print $6, $7}')
# fixed width: size=6, date=6 → total detail = 14 chars
# 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 - 1 - _dlen - 1))
maxw=$((_cols - 2 - _dlen))
if [ "${#display}" -gt "$maxw" ]; then
display=$(printf '%s' "$display" | cut -c1-$((_cols - _dlen - 4)))
_trunc=$((maxw - 3))
while [ "${#display}" -gt "$_trunc" ]; do
display="${display%?}"
done
display="${display}..."
fi
_namew=$((${#display} + 1)) # 1 for marker
@@ -468,7 +440,7 @@ draw_preview() {
_dl=" ${_dn}/"
[ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~"
goto "$_pr" "$_px"
printf '%s%s%s' "${GREEN}${BOLD}" "$_dl" "${RESET}"
printf '%s%s%s' "${BLUE}${BOLD}" "$_dl" "${RESET}"
_pr=$((_pr + 1))
done
for _fe in "$_path"/*; do
@@ -681,15 +653,21 @@ draw() {
read_key() {
IFS= read -r -n1 key 2>/dev/null || IFS= read -r key
if [ "$key" = "$(printf '\033')" ]; then
# peek at next char with a very short timeout
IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2=""
if [ -z "$_k2" ]; then
# nothing followed — bare esc
printf '\033'; return
fi
# got a second char — read one more for full sequence (e.g. [A [B [C [D)
IFS= read -r -n1 -t 0.1 _k3 2>/dev/null || _k3=""
# 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"
}
@@ -799,8 +777,10 @@ open_file() {
# 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=""
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
@@ -855,7 +835,7 @@ do_open_with() {
do_newfile() {
goto "$(term_rows)" 1
printf '%s%s New file name (esc=cancel): %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}"
printf '%s%s File name: %s' "${ERASE_LINE}" "${WHITE}${BOLD}" "${RESET}"
if read_line && [ -n "$READ_LINE" ]; then
touch "${CWD}/${READ_LINE}"
load_entries
@@ -891,7 +871,7 @@ do_help() {
bw=$((COLS - 4))
[ "$bw" -gt 52 ] && bw=52
[ "$bw" -lt 36 ] && bw=36
bh=42
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))
@@ -929,17 +909,21 @@ do_help() {
help_row 27 " U open trash directory"
help_row 28 " ! drop to shell in CWD"
help_row 29 " o open with custom program"
help_sep 30
help_row 31 " b bookmark current dir"
help_row 32 " B open bookmark picker"
help_row 33 " c copy path to clipboard"
help_row 34 " ~ go to home directory"
help_row 35 " \` jump to previous directory"
help_sep 36
help_row 37 " q quit"
help_row 38 " ? this help"
help_row 39 ""
help_row 40 " press any key to close..."
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}"
@@ -1020,6 +1004,141 @@ do_toggle_preview() {
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=""
@@ -1097,19 +1216,93 @@ do_open() {
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 ---
restore_term; show_cursor
goto "$(term_rows)" 1
printf '%s%s Delete %d selected items? [y/N] %s' \
"${ERASE_LINE}" "${RED}${BOLD}" "$_c" "${RESET}"
IFS= read -r ans
setup_term; hide_cursor
case "$ans" in
y|Y)
_prompt="Delete ${_c} selected items?"
confirm_overlay; _yes=$?
if [ "$_yes" -eq 0 ]; then
_rest="$SELECTED"
while [ -n "$_rest" ]; do
_line="${_rest%%
@@ -1119,60 +1312,53 @@ do_delete() {
[ "$_next" = "$_rest" ] && _next=""
_rest="$_next"
[ -z "$_line" ] && continue
_t="${CWD}/${_line%/}"
_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
;;
*) NEED_FULL_REDRAW=1 ;;
esac
else
NEED_FULL_REDRAW=1
fi
else
# --- single delete ---
entry=$(get_entry "$SEL")
target="${CWD}/${entry%/}"
restore_term; show_cursor
goto "$(term_rows)" 1
printf '%s%s Delete "%s"? [y/N] %s' "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}"
IFS= read -r ans
setup_term; hide_cursor
case "$ans" in
y|Y)
_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
restore_term; show_cursor
goto "$(term_rows)" 1
printf '%s%s "%s" not empty. Delete ALL? [y/N] %s' \
"${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}"
IFS= read -r ans2
setup_term; hide_cursor
case "$ans2" in
y|Y) rm -rf "$target" ;;
*) NEED_FULL_REDRAW=1; return ;;
esac
else
rm -rf "$target"
_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
;;
*) NEED_FULL_REDRAW=1 ;;
esac
fi
}
do_rename() {
entry=$(get_entry "$SEL")
_name="${entry%@}"; _name="${_name%/}"
goto "$(term_rows)" 1
printf '%s%s Rename "%s" to (esc=cancel): %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "$entry" "${RESET}"
if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$entry" ]; then
mv "${CWD}/${entry%/}" "${CWD}/${READ_LINE}"
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
@@ -1181,7 +1367,7 @@ do_rename() {
do_mkdir() {
goto "$(term_rows)" 1
printf '%s%s New dir name (esc=cancel): %s' "${ERASE_LINE}" "${CYAN}${BOLD}" "${RESET}"
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
@@ -1190,18 +1376,125 @@ do_mkdir() {
fi
}
do_chmod_x() {
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
_target=$(joinpath "${entry%@}")
if chmod +x "$_target" 2>/dev/null; then
INFO_MSG="chmod +x: ${entry}"
else
INFO_MSG="chmod failed: ${entry}"
fi
NEED_FULL_REDRAW=1
}
do_chmod_nox() {
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
case "$entry" in */) INFO_MSG="cannot chmod a directory"; NEED_FULL_REDRAW=1; return ;; esac
_target=$(joinpath "${entry%@}")
if chmod -x "$_target" 2>/dev/null; then
INFO_MSG="chmod -x: ${entry}"
else
INFO_MSG="chmod failed: ${entry}"
fi
NEED_FULL_REDRAW=1
}
do_info() {
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
target="${CWD}/${entry%/}"
# permissions + type
_perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}')
# size (human readable via du, fallback to ls)
_size=$(du -sh "$target" 2>/dev/null | cut -f1)
[ -z "$_size" ] && _size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}')
# modification date
_date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}')
INFO_MSG="${_perm} ${_size} ${_date}"
_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
}
@@ -1231,9 +1524,10 @@ do_sort() {
do_trash() {
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
target="${CWD}/${entry%/}"
_name="${entry%@}"; _name="${_name%/}"
target="${CWD}/${_name}"
_ts=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || date '+%s')
_dest="${TRASH_DIR}/${_ts}_${entry%/}"
_dest="${TRASH_DIR}/${_ts}_${_name}"
if mv "$target" "$_dest"; then
INFO_MSG="trashed: ${entry}"
[ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
@@ -1256,7 +1550,8 @@ do_open_trash() {
do_copy_path() {
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
_path="${CWD}/${entry%/}"
_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
@@ -1314,6 +1609,8 @@ do_bookmark_jump() {
_hl=$(printf '%*s' "$_iw" '' | tr ' ' '-')
_bsel=1
_bvis=$((_ph - 3))
_boff=1
while true; do
# draw picker inline
goto "$_py" "$_px"
@@ -1323,11 +1620,23 @@ do_bookmark_jump() {
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))
goto "$((_py+2+_bi))" "$_px"
[ "$_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" '')
@@ -1341,14 +1650,18 @@ do_bookmark_jump() {
printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk
case "$_bk" in
j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;;
k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;;
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 ;;
'[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;;
'[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
@@ -1424,7 +1737,8 @@ do_yank() {
if [ "$_c" -gt 0 ]; then
CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
[ -z "$_e" ] && continue
printf '%s\n' "${CWD}/${_e%/}"
_yname="${_e%@}"; _yname="${_yname%/}"
printf '%s\n' "${CWD}/${_yname}"
done)
CLIP_MODE="copy"
INFO_MSG="yanked ${_c} items"
@@ -1432,7 +1746,8 @@ do_yank() {
else
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
CLIPBOARD="${CWD}/${entry%/}"
_yname="${entry%@}"; _yname="${_yname%/}"
CLIPBOARD="${CWD}/${_yname}"
CLIP_MODE="copy"
INFO_MSG="yanked: ${entry}"
fi
@@ -1444,7 +1759,8 @@ do_cut() {
if [ "$_c" -gt 0 ]; then
CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
[ -z "$_e" ] && continue
printf '%s\n' "${CWD}/${_e%/}"
_cname="${_e%@}"; _cname="${_cname%/}"
printf '%s\n' "${CWD}/${_cname}"
done)
CLIP_MODE="cut"
INFO_MSG="cut ${_c} items"
@@ -1452,7 +1768,8 @@ do_cut() {
else
entry=$(get_entry "$SEL")
[ -z "$entry" ] && return
CLIPBOARD="${CWD}/${entry%/}"
_cname="${entry%@}"; _cname="${_cname%/}"
CLIPBOARD="${CWD}/${_cname}"
CLIP_MODE="cut"
INFO_MSG="cut: ${entry}"
fi
@@ -1465,7 +1782,6 @@ do_paste() {
NEED_FULL_REDRAW=1
return
fi
_ok=0; _fail=0
printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do
[ -z "$_src" ] && continue
_name="${_src##*/}"
@@ -1476,8 +1792,8 @@ do_paste() {
_dest="${CWD}/${_base}_copy${_ext}"
fi
case "$CLIP_MODE" in
copy) cp -r "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;;
cut) mv "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;;
copy) cp -r "$_src" "$_dest" ;;
cut) mv "$_src" "$_dest" ;;
esac
done
CLIPBOARD=""; CLIP_MODE=""
@@ -1486,7 +1802,7 @@ do_paste() {
}
# --- main ---
trap 'restore_term; show_cursor; printf "\033[2J\033[H"; exit 0' INT TERM EXIT
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
@@ -1506,6 +1822,7 @@ while true; do
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')"|\
@@ -1521,12 +1838,14 @@ while true; do
T) do_toggle_details ;;
P) do_toggle_preview ;;
i) do_info ;;
'!') do_shell ;;
'+') do_chmod_x ;;
'-') do_chmod_nox ;;
o) do_open_with ;;
s) do_sort ;;
u) do_trash ;;
U) do_open_trash ;;
'`') do_jump_back ;;
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 ;;
@@ -1541,6 +1860,7 @@ while true; do
m) do_mkdir ;;
n) do_newfile ;;
q|Q) break ;;
'!') do_shell ;;
esac
done
+376
View File
@@ -0,0 +1,376 @@
.TH SFM 1 "2026-03-16" "0.4" "Simple File Manager"
.SH NAME
sfm \- Simple File Manager
.SH SYNOPSIS
.B sfm
.RI [ path ]
.SH DESCRIPTION
.B 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. Designed to be fast, flicker-free, and keyboard-driven with a
vim-inspired key layout.
If
.I path
is given, sfm opens that directory. Otherwise it opens in the current
working directory.
.SH KEY BINDINGS
.SS Navigation
.TP
.BR j ", " k " or " "up/down arrows"
Move cursor up or down.
.TP
.BR h " or " "left arrow"
Go to parent directory.
.TP
.BR l ", " "right arrow" ", or " enter
Open selected file or enter directory.
.TP
.B g
Jump to top of list.
.TP
.B G
Jump to bottom of list.
.TP
.B ~
Go to home directory.
.TP
.B \`
Jump to previous directory (toggle).
.TP
.B :
Prompt for a path to jump to directly. Supports
.B ~
expansion. Shows an error if the path does not exist. Press esc to cancel.
.TP
.B f
Find files recursively under the current directory. Opens an interactive
picker. Press j/k or arrows to navigate results, enter or l to jump to the
selected result, esc/q/h to close.
.SS Search and Filter
.TP
.B /
Enter search mode. The listing is filtered as you type.
.TP
.B esc
Clear filter and exit search mode.
.TP
.B enter
Exit search mode while keeping the current filter active.
.SS Display Toggles
.TP
.B .
Toggle hidden files (dotfiles). Hidden entries appear before regular
entries (hidden dirs, then regular dirs, then hidden files, then
regular files).
.TP
.B T
Toggle size/date detail column.
.TP
.B P
Toggle preview pane on the right side.
.TP
.B s
Cycle sort mode: name \-> size \-> date.
.TP
.B i
Show file info (permissions, size, date) in the status bar.
.TP
.B R
Refresh the current directory listing.
.SS File Operations
.TP
.B r
Rename selected entry. Press esc to cancel.
.TP
.B m
Create a new directory. Press esc to cancel.
.TP
.B n
Create a new empty file. Press esc to cancel.
.TP
.B d " or " del
Delete selected entry or all multi-selected entries.
Non-empty directories require a second confirmation.
.TP
.B u
Move selected entry to trash (safe delete).
.TP
.B U
Open the trash directory.
.TP
.B o
Open selected file with a custom program. Press esc to cancel.
.TP
.B +
Make the selected file executable
.RB ( "chmod +x" ).
.TP
.B \-
Remove execute permission from the selected file
.RB ( "chmod -x" ).
.SS Clipboard
.TP
.B y
Yank (copy) selected entry or all multi-selected entries.
.TP
.B x
Cut selected entry or all multi-selected entries.
.TP
.B p
Paste clipboard contents into the current directory.
Auto-renames on conflict by appending
.IR _copy .
.TP
.B c
Copy the full path of the selected entry to the system clipboard.
Requires
.BR wl-copy ", " xclip ", " xsel ", or " pbcopy .
.SS Multi-Select
.TP
.B space
Toggle multi-select on the current entry (cursor advances).
.TP
.B a
Select all entries / deselect all.
.PP
Multi-select works with
.BR y ", " x ", and " d .
.SS Bookmarks
.TP
.B b
Bookmark the current directory. Press again to remove it.
.TP
.B B
Open the bookmark picker.
Use j/k to navigate, enter to jump, esc to close.
.SS Other
.TP
.B !
Drop into
.B $SHELL
in the current directory. Type
.I exit
to return to sfm. The working directory is updated if you cd elsewhere
in the shell.
.TP
.B ?
Show the keyboard shortcuts help overlay.
.TP
.B q
Quit sfm.
.SH PREVIEW PANE
When the preview pane is enabled with
.BR P ,
the terminal is split into a left pane (file list) and a right pane
(preview). The preview shows:
.IP \(bu 2
.B Text files \-
file contents displayed line by line.
.IP \(bu 2
.B Directories \-
directory contents (subdirectories first, then files).
.IP \(bu 2
.B Symlinks \-
the link target path.
.IP \(bu 2
.B Binary files \-
file size.
.SH CUSTOM OPENER
.B sfm
checks for a user-defined opener script at:
.PP
.I ~/.config/sfm/opener
.PP
If the file exists and is executable, sfm passes the selected file path
to it as the first argument instead of using the built-in smart opener.
.PP
Example opener script:
.PP
.nf
#!/bin/sh
case "$1" in
*.jpg|*.jpeg) imv "$1" ;;
*.mp4) mpv "$1" ;;
*.html|*.pdf) firefox "$1" ;;
*) echo "no program set for this file type" ;;
esac
.fi
.PP
Make it executable with:
.B chmod +x ~/.config/sfm/opener
.PP
If the opener script does not exist, sfm falls back to its built-in
smart opener which detects file types by extension and MIME type.
.SH SMART FILE OPENER
When no custom opener is configured, sfm opens files using the first
available program for each file type:
.TP
.B Text / code
$EDITOR or vi.
.TP
.B Images
imv, feh, sxiv, eog, gimp.
.TP
.B Video
mpv, vlc, mplayer, totem.
.TP
.B Audio
mpv, vlc, cmus, mocp.
.TP
.B PDF
zathura, evince, okular, mupdf.
.TP
.B Office documents
libreoffice.
.TP
.B Archives
atool or bsdtar (lists contents in pager).
.PP
Falls back to xdg-open, open (macOS), or $EDITOR.
.SH FILE COLOURS
.TP
.B Green bold
Directories.
.TP
.B Red bold
Executables and broken symlinks.
.TP
.B Magenta
Images.
.TP
.B Magenta bold
Video files and valid symlinks.
.TP
.B Cyan bold
Audio files.
.TP
.B Red
Archives (zip, tar, gz, etc.).
.TP
.B Yellow
Documents (pdf, docx, etc.).
.TP
.B Green
Code and scripts (py, sh, rs, etc.).
.TP
.B Cyan
Config and data files (json, yaml, etc.).
.TP
.B White
Text, markdown, and all other files.
.SH STATUS BAR INDICATORS
.TP
.B [hidden]
Hidden (dot) files are currently visible.
.TP
.B [sort:size] or [sort:date]
Active sort mode. Not shown when sorting by name (default).
.TP
.B [copy] or [cut]
A file is stored in the clipboard.
.TP
.B [sel:N]
N items are currently multi-selected.
.TP
.B [details]
Size/date detail column is visible.
.SH TRASH
Files deleted with
.B u
are moved to:
.PP
.I ~/.local/share/sfm-trash/
.PP
Files are prefixed with a timestamp in the format
.IR YYYYMMDD_HHMMSS_filename .
Use
.B U
to browse the trash directory. To restore a file, rename it with
.B r
to remove the timestamp prefix, then cut with
.B x
and paste with
.B p
to the desired location.
.SH FILES
.TP
.I ~/.config/sfm/bookmarks
Saved bookmarks, one directory path per line.
.TP
.I ~/.config/sfm/opener
Custom file opener script (optional, must be executable).
.TP
.I ~/.local/share/sfm-trash/
Directory where trashed files are stored.
.SH ENVIRONMENT
.TP
.B EDITOR
Editor used to open text files when no custom opener is set.
.TP
.B SHELL
Shell used when dropping to the terminal with
.BR ! .
.TP
.B PAGER
Pager used when listing archive contents.
.TP
.B XDG_CONFIG_HOME
Base directory for sfm config files. Defaults to
.IR ~/.config .
.TP
.B XDG_DATA_HOME
Base directory for sfm data files (trash). Defaults to
.IR ~/.local/share .
.SH EXAMPLES
Open sfm in the current directory:
.PP
.nf
sfm
.fi
.PP
Open sfm at a specific path:
.PP
.nf
sfm /mnt/data
.fi
.PP
Use sfm as a cd wrapper (add to shell rc):
.PP
.nf
s() { cd "$(sfm)"; }
.fi
.SH AUTHORS
Emmett1 <me@emmett1.my> with help of AI.
.SH SEE ALSO
.BR find (1),
.BR ls (1),
.BR mv (1),
.BR cp (1),
.BR rm (1),
.BR chmod (1),
.BR vi (1)