14 Commits
Author SHA1 Message Date
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
emmett1 6b729ffe8a updated panel mode 2026-03-28 07:54:42 +08:00
emmett1 25a329aba9 added opener script support 2026-03-28 00:08:08 +08:00
emmett1 6f9feaf9b4 updated README.txt 2026-03-16 00:44:07 +08:00
emmett1 fbc3227515 added preview function 2026-03-16 00:36:44 +08:00
emmett1 02a8adadc4 fix when browse into empty directory 2026-03-16 00:21:22 +08:00
emmett1 1e6fdd9e29 add function to run from target directory 2026-03-16 00:14:29 +08:00
emmett1 12c85202f9 fix double slash at directory name 2026-03-16 00:11:24 +08:00
emmett1 7f41df91f1 fix symlink show twice, one as symlink, one as directory 2026-03-15 23:58:18 +08:00
4 changed files with 852 additions and 134 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."
+45 -2
View File
@@ -17,6 +17,7 @@ REQUIREMENTS
- Optional: wl-copy / xclip / xsel / pbcopy (for clipboard support)
- Optional: mpv, vlc, feh, zathura, etc. (for smart file opening)
- Optional: readlink (for symlink display)
- Optional: file (for MIME type detection)
INSTALLATION
@@ -41,9 +42,15 @@ Manual installation:
USAGE
-----
sfm
sfm [path]
sfm will opens in the current directory.
If no path is given, sfm opens in the current directory.
Examples:
sfm # open in current directory
sfm /mnt/data # open at a specific path
sfm ~ # open home directory
sfm .. # open parent directory
NAVIGATION
@@ -68,11 +75,46 @@ DISPLAY TOGGLES
---------------
. Toggle hidden files (dotfiles)
T Toggle size/date detail column
P Toggle preview pane (right side)
s Cycle sort mode: name -> size -> date
i Show file info (permissions, size, date) in status bar
R Refresh current directory listing
PREVIEW PANE
------------
Press P to toggle the preview pane on the right side of the screen.
The list pane takes the left half, preview takes the right half.
Text files Shows file contents line by line
Directories Shows directory contents
Symlinks Shows link target
Binary files Shows file size
CUSTOM OPENER
-------------
sfm checks for a user-defined opener script at:
~/.config/sfm/opener
If the file exists and is executable, sfm passes the selected file
path to it instead of using the built-in smart opener. Example:
#!/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
Make it executable: chmod +x ~/.config/sfm/opener
If the opener script does not exist, sfm falls back to its built-in
smart opener automatically.
FILE OPERATIONS
---------------
r Rename selected entry
@@ -163,5 +205,6 @@ it back to the desired location.
DATA FILES
----------
~/.config/sfm/bookmarks Saved bookmarks (one path per line)
~/.config/sfm/opener Custom file opener script (optional)
~/.local/share/sfm-trash/ Trashed files
+405 -79
View File
@@ -61,7 +61,11 @@ term_rows() { tput_cmd lines || echo 24; }
term_cols() { tput_cmd cols || echo 80; }
# --- state ---
CWD="$PWD"
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
@@ -79,9 +83,20 @@ INFO_MSG="" # ephemeral message shown in botbar
SHOW_HIDDEN=0 # 1 = show dotfiles
SORT_MODE="name" # name | size | date
SHOW_DETAILS=0 # 1 = show size+date column
SHOW_PREVIEW=0 # 1 = show preview pane on right
TRASH_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/sfm-trash"
mkdir -p "$TRASH_DIR"
PREV_CWD="" # last visited directory, for jumping back
# normalise CWD: resolve to absolute canonical path, no double slashes
norm_cwd() {
CWD=$(cd "$CWD" 2>/dev/null && pwd) || CWD="/"
# squeeze any double slashes (some systems return // for //<path>)
CWD=$(printf '%s' "$CWD" | tr -s '/')
}
# safely join CWD with a name, avoiding double slash at root
joinpath() { [ "$CWD" = "/" ] && printf '/%s' "$1" || printf '%s/%s' "$CWD" "$1"; }
BOOKMARK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/sfm/bookmarks"
mkdir -p "$(dirname "$BOOKMARK_FILE")"
[ -f "$BOOKMARK_FILE" ] || touch "$BOOKMARK_FILE"
@@ -91,20 +106,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
name="${d%/}"; name="${name##*/}"
[ "$name" = "*" ] && continue
case "$name" in .*) continue ;; esac # skip dotdirs here, handle below
ENTRIES="$ENTRIES
${name}/"
COUNT=$((COUNT + 1))
done
# hidden dirs first
if [ "$SHOW_HIDDEN" = "1" ]; then
for d in "$CWD"/./; do : ; done # no-op, just to avoid error
for d in "$CWD"/.*/; do
[ -d "$d" ] || continue
[ -L "${d%/}" ] && continue
name="${d%/}"; name="${name##*/}"
[ "$name" = "." ] || [ "$name" = ".." ] && continue
[ "$name" = ".*" ] && continue
@@ -114,21 +120,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
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
@@ -146,6 +150,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#
}"
@@ -416,6 +437,111 @@ render_row() {
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' "${GREEN}${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" '')
@@ -426,7 +552,7 @@ draw_topbar() {
draw_botbar() {
_rows=$1; _cols=$2
if [ "$SEARCHING" = "1" ]; then
info=" $((SEL + 1))/${COUNT} ${CWD}"
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")
@@ -440,9 +566,8 @@ draw_botbar() {
"${YELLOW}${keys}" \
"${RESET}"
elif [ -n "$INFO_MSG" ]; then
info=" $((SEL + 1))/${COUNT} ${CWD}"
msg=" ${INFO_MSG} "
padlen=$((_cols - ${#info} - ${#msg}))
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
@@ -461,7 +586,7 @@ draw_botbar() {
[ "$SORT_MODE" != "name" ] && _ind="${_ind} [sort:${SORT_MODE}]"
[ "$SHOW_DETAILS" = "1" ] && _ind="${_ind} [details]"
hint=" press ? for help "
info=" $((SEL + 1))/${COUNT} ${CWD}${_ind}"
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" '')
@@ -479,6 +604,15 @@ draw() {
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))
@@ -494,7 +628,6 @@ draw() {
[ "$OFFSET" != "$PREV_OFFSET" ] && NEED_FULL_REDRAW=1
if [ "$NEED_FULL_REDRAW" = "1" ]; then
# --- full repaint: move to top without erasing, overwrite every row ---
printf '\033[H'
draw_topbar "$COLS"
@@ -502,22 +635,29 @@ draw() {
idx=$OFFSET
while [ "$row" -le $((LIST_ROWS + 1)) ] && [ "$idx" -lt "$COUNT" ]; do
sel=0; [ "$idx" -eq "$SEL" ] && sel=1
render_row "$row" "$idx" "$sel" "$COLS"
render_row "$row" "$idx" "$sel" "$LIST_COLS"
row=$((row + 1))
idx=$((idx + 1))
done
# blank leftover rows (e.g. after entering a smaller directory)
# 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: only touch the two rows whose highlight state changed ---
# if nothing moved, skip entirely — no flicker at boundaries
# --- fast path ---
if [ "$SEL" = "$PREV_SEL" ] && [ "$OFFSET" = "$PREV_OFFSET" ]; then
return 2>/dev/null || true
fi
@@ -525,12 +665,14 @@ draw() {
new_row=$(( (SEL - OFFSET) + 2 ))
if [ "$prev_row" -ge 2 ] && [ "$prev_row" -le $((LIST_ROWS + 1)) ]; then
render_row "$prev_row" "$PREV_SEL" 0 "$COLS"
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 "$COLS"
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
@@ -567,6 +709,14 @@ _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:]')
@@ -744,7 +894,7 @@ do_help() {
bw=$((COLS - 4))
[ "$bw" -gt 52 ] && bw=52
[ "$bw" -lt 36 ] && bw=36
bh=41
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))
@@ -766,32 +916,37 @@ do_help() {
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_sep 14
help_row 15 " space toggle multi-select"
help_row 16 " a select all / deselect all"
help_row 17 " y yank/copy (works on selection)"
help_row 18 " x cut (works on selection)"
help_row 19 " p paste"
help_row 20 " d delete (works on selection)"
help_sep 21
help_row 22 " r rename R refresh"
help_row 23 " m make directory"
help_row 24 " n new file"
help_row 25 " u trash file (safe delete)"
help_row 26 " U open trash directory"
help_row 27 " ! drop to shell in CWD"
help_row 28 " o open with custom program"
help_sep 29
help_row 30 " b bookmark current dir"
help_row 31 " B open bookmark picker"
help_row 32 " c copy path to clipboard"
help_row 33 " ~ go to home directory"
help_row 34 " \` jump to previous directory"
help_sep 35
help_row 36 " q quit"
help_row 37 " ? this help"
help_row 38 ""
help_row 39 " press any key to close..."
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}"
@@ -856,22 +1011,164 @@ do_toggle_hidden() {
do_toggle_details() {
if [ "$SHOW_DETAILS" = "0" ]; then
SHOW_DETAILS=1
INFO_MSG="details on"
SHOW_DETAILS=1; INFO_MSG="details on"
else
SHOW_DETAILS=0
INFO_MSG="details off"
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=$(dirname "$CWD")
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
@@ -888,20 +1185,20 @@ do_open() {
*/)
FILTER=""
SELECTED=""
_target="${CWD}/${entry%/}"
_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)
CWD=$(cd "$_target" && pwd); norm_cwd
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
load_entries
;;
*@)
_name="${entry%@}"
_target="${CWD}/${_name}"
_target=$(joinpath "$_name")
if [ -d "$_target" ]; then
if [ ! -x "$_target" ] || [ ! -r "$_target" ]; then
INFO_MSG="permission denied: ${_name}"
@@ -910,7 +1207,7 @@ do_open() {
fi
FILTER=""; SELECTED=""
PREV_CWD="$CWD"
CWD=$(cd "$_target" && pwd)
CWD=$(cd "$_target" && pwd); norm_cwd
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
load_entries
else
@@ -1035,6 +1332,32 @@ 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
@@ -1057,7 +1380,7 @@ do_shell() {
printf '%s(type "exit" to return to fm)%s\n' "${YELLOW}" "${RESET}"
${SHELL:-sh}
# restore CWD in case user cd'd around
CWD="${PWD}"
CWD="${PWD}"; norm_cwd
setup_term; hide_cursor
NEED_FULL_REDRAW=1
}
@@ -1093,7 +1416,7 @@ do_trash() {
do_open_trash() {
PREV_CWD="$CWD"
FILTER=""; SELECTED=""
CWD="$TRASH_DIR"
CWD="$TRASH_DIR"; norm_cwd
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
load_entries
}
@@ -1122,7 +1445,7 @@ do_copy_path() {
do_jump_back() {
[ -z "$PREV_CWD" ] && { INFO_MSG="no previous directory"; NEED_FULL_REDRAW=1; return; }
_tmp="$CWD"
CWD="$PREV_CWD"
CWD="$PREV_CWD"; norm_cwd
PREV_CWD="$_tmp"
FILTER=""; SELECTED=""
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
@@ -1197,7 +1520,7 @@ do_bookmark_jump() {
'[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"
PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
FILTER=""; SELECTED=""
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
load_entries
@@ -1213,7 +1536,7 @@ do_bookmark_jump() {
l)
_chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE")
if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then
PREV_CWD="$CWD"; CWD="$_chosen"
PREV_CWD="$CWD"; CWD="$_chosen"; norm_cwd
FILTER=""; SELECTED=""
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0
load_entries
@@ -1364,14 +1687,17 @@ while true; do
/) do_search ;;
'.') do_toggle_hidden ;;
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 ;;
'~') PREV_CWD="$CWD"; CWD="$HOME"; FILTER=""; SELECTED=""
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 ;;
+345
View File
@@ -0,0 +1,345 @@
.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).
.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).
.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
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.
.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.
.TP
.B c
Copy the full path of 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.
.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.
.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
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
Built interactively with Claude (Anthropic).
.SH SEE ALSO
.BR ls (1),
.BR mv (1),
.BR cp (1),
.BR rm (1),
.BR vi (1)