18 Commits
Author SHA1 Message Date
emmett1 40e5d2815c added cpp version, sh version as backup 2026-06-16 07:43:51 +08:00
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
6 changed files with 3332 additions and 238 deletions
+2
View File
@@ -0,0 +1,2 @@
CLAUDE.md
sfm
+22 -9
View File
@@ -5,29 +5,42 @@ BINDIR ?= $(PREFIX)/bin
MANDIR ?= $(PREFIX)/share/man/man1 MANDIR ?= $(PREFIX)/share/man/man1
DOCDIR ?= $(PREFIX)/share/doc/sfm DOCDIR ?= $(PREFIX)/share/doc/sfm
CXX ?= g++
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra
LDFLAGS ?= -lncursesw
INSTALL ?= install INSTALL ?= install
RM ?= rm -f RM ?= rm -f
.PHONY: all install uninstall clean .PHONY: all install uninstall clean
all: all: sfm
@echo "sfm is a shell script — nothing to build."
@echo "Run 'make install' to install."
install: sfm: sfm.cpp
@echo "Installing sfm to $(BINDIR)/sfm ..." $(CXX) $(CXXFLAGS) -o $@ $< $(LDFLAGS)
install: all
@echo "Installing sfm to $(DESTDIR)$(BINDIR)/sfm ..."
$(INSTALL) -d $(DESTDIR)$(BINDIR) $(INSTALL) -d $(DESTDIR)$(BINDIR)
$(INSTALL) -m 755 sfm $(DESTDIR)$(BINDIR)/sfm $(INSTALL) -m 755 sfm $(DESTDIR)$(BINDIR)/sfm
@echo "Installing README to $(DOCDIR) ..." @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) -d $(DESTDIR)$(DOCDIR)
$(INSTALL) -m 644 README.txt $(DESTDIR)$(DOCDIR)/README.txt $(INSTALL) -m 644 README $(DESTDIR)$(DOCDIR)/README
@echo "Done. Run 'sfm' to start." @echo "Done."
uninstall: uninstall:
@echo "Removing sfm ..." @echo "Removing sfm ..."
$(RM) $(DESTDIR)$(BINDIR)/sfm $(RM) $(DESTDIR)$(BINDIR)/sfm
$(RM) $(DESTDIR)$(BINDIR)/sfm.sh
$(RM) $(DESTDIR)$(MANDIR)/sfm.1
$(RM) -r $(DESTDIR)$(DOCDIR) $(RM) -r $(DESTDIR)$(DOCDIR)
@echo "Done." @echo "Done."
clean: clean:
@echo "Nothing to clean." $(RM) sfm
@echo "Cleaned."
+17 -8
View File
@@ -4,16 +4,23 @@ sfm - Simple File Manager
DESCRIPTION DESCRIPTION
----------- -----------
sfm is a lightweight, terminal-based file manager written entirely in sfm is a lightweight, terminal-based file manager with a vim-inspired
POSIX sh. It runs in your terminal with no external dependencies beyond key layout. The primary version is a fast C++/ncurses binary. A portable
standard Unix tools (ls, awk, tput, stty, mv, cp, rm). Designed to be POSIX sh version (sfm.sh) is also included, requiring only standard Unix
fast, flicker-free, and keyboard-driven with a vim-inspired key layout. tools (ls, awk, tput, stty, mv, cp, rm).
REQUIREMENTS 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.) - A POSIX-compatible shell (sh, dash, bash, etc.)
- Standard Unix tools: ls, awk, tput, stty, cp, mv, rm, mkdir, touch - Standard Unix tools: ls, awk, tput, stty, cp, mv, rm, mkdir, touch
Both versions:
- Optional: wl-copy / xclip / xsel / pbcopy (for clipboard support) - Optional: wl-copy / xclip / xsel / pbcopy (for clipboard support)
- Optional: mpv, vlc, feh, zathura, etc. (for smart file opening) - Optional: mpv, vlc, feh, zathura, etc. (for smart file opening)
- Optional: readlink (for symlink display) - Optional: readlink (for symlink display)
@@ -26,7 +33,9 @@ Using make (recommended):
make install 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 make install PREFIX=/usr
@@ -34,10 +43,10 @@ To uninstall:
make uninstall make uninstall
Manual installation: Manual installation (shell version only):
cp sfm /usr/local/bin/sfm cp sfm.sh /usr/local/bin/sfm.sh
chmod +x /usr/local/bin/sfm chmod +x /usr/local/bin/sfm.sh
USAGE USAGE
+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)
+2374
View File
File diff suppressed because it is too large Load Diff
+509 -189
View File
@@ -1,6 +1,5 @@
#!/bin/sh #!/bin/sh
# sfm - Simple File Manager in POSIX sh (flicker-free) # sfm - Simple File Manager in POSIX sh
# version 0.4
# --- terminal control --- # --- terminal control ---
tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; } tput_cmd() { command -v tput >/dev/null 2>&1 && tput "$@"; }
@@ -17,44 +16,6 @@ MAGENTA=$(tput_cmd setaf 5)
BLUE=$(tput_cmd setaf 4) BLUE=$(tput_cmd setaf 4)
ERASE_LINE=$(tput_cmd el || printf '\033[K') 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"; } goto() { printf '\033[%d;%dH' "$1" "$2"; }
hide_cursor() { printf '\033[?25l'; } hide_cursor() { printf '\033[?25l'; }
show_cursor() { printf '\033[?25h'; } show_cursor() { printf '\033[?25h'; }
@@ -107,22 +68,11 @@ load_entries() {
ENTRIES="" ENTRIES=""
COUNT=0 COUNT=0
# build list of dir names then file names, including dotfiles if enabled # hidden dirs first
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
if [ "$SHOW_HIDDEN" = "1" ]; then if [ "$SHOW_HIDDEN" = "1" ]; then
for d in "$CWD"/./; do : ; done
for d in "$CWD"/.*/; do for d in "$CWD"/.*/; do
[ -d "$d" ] || continue [ -d "$d" ] || continue
[ -L "${d%/}" ] && continue # symlinks handled separately [ -L "${d%/}" ] && continue
name="${d%/}"; name="${name##*/}" name="${d%/}"; name="${name##*/}"
[ "$name" = "." ] || [ "$name" = ".." ] && continue [ "$name" = "." ] || [ "$name" = ".." ] && continue
[ "$name" = ".*" ] && continue [ "$name" = ".*" ] && continue
@@ -132,21 +82,19 @@ ${name}/"
done done
fi fi
for f in "$CWD"/*; do # regular dirs
[ -e "$f" ] || [ -L "$f" ] || continue for d in "$CWD"/*/; do
[ -d "$f" ] && ! [ -L "$f" ] && continue [ -d "$d" ] || continue
name="${f##*/}" [ -L "${d%/}" ] && continue
name="${d%/}"; name="${name##*/}"
[ "$name" = "*" ] && continue [ "$name" = "*" ] && continue
case "$name" in .*) continue ;; esac case "$name" in .*) continue ;; esac
if [ -L "$f" ]; then
ENTRIES="$ENTRIES ENTRIES="$ENTRIES
${name}@" ${name}/"
else
ENTRIES="$ENTRIES
${name}"
fi
COUNT=$((COUNT + 1)) COUNT=$((COUNT + 1))
done done
# hidden files first
if [ "$SHOW_HIDDEN" = "1" ]; then if [ "$SHOW_HIDDEN" = "1" ]; then
for f in "$CWD"/.*; do for f in "$CWD"/.*; do
[ -e "$f" ] || [ -L "$f" ] || continue [ -e "$f" ] || [ -L "$f" ] || continue
@@ -164,6 +112,23 @@ ${name}"
done done
fi 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# ENTRIES="${ENTRIES#
}" }"
@@ -192,8 +157,8 @@ $_line"; fi ;;
# sort files using ls into a tmp file # sort files using ls into a tmp file
if [ -n "$_files" ]; then if [ -n "$_files" ]; then
case "$SORT_MODE" in case "$SORT_MODE" in
size) ls -1Sp "$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 ;; date) ls -1tp "$CWD" 2>/dev/null | grep -v '/' > /tmp/_fm_sorted_$$ ;;
esac esac
# only keep files that were already in our list (respects hidden filter) # only keep files that were already in our list (respects hidden filter)
_sorted="" _sorted=""
@@ -208,7 +173,7 @@ ${_n}
else _sorted="$_sorted else _sorted="$_sorted
$_n"; fi ;; $_n"; fi ;;
esac esac
done < /tmp/_fm_sorted done < /tmp/_fm_sorted_$$
_files="$_sorted" _files="$_sorted"
fi fi
@@ -262,50 +227,47 @@ apply_filter() {
if [ -z "$FILTER" ]; then if [ -z "$FILTER" ]; then
ENTRIES="$ALL_ENTRIES" ENTRIES="$ALL_ENTRIES"
COUNT="$ALL_COUNT" COUNT="$ALL_COUNT"
return else
fi
ENTRIES="" ENTRIES=""
COUNT=0 COUNT=0
printf '%s\n' "$ALL_ENTRIES" | while IFS= read -r line; do _rest="$ALL_ENTRIES"
case "$line" in while [ -n "$_rest" ]; do
*"$FILTER"*) printf '%s\n' "$line" ;; _line="${_rest%%
esac *}"; _next="${_rest#*
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#
}" }"
[ "$_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() { 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 index of entry by name (0-based), returns -1 if not found
find_entry() { find_entry() {
printf '%s\n' "$ENTRIES" | awk -v name="$1" ' _fe_n=$(grep -nFx "$1" /tmp/_sfm_list_$$ 2>/dev/null | head -1 | cut -d: -f1)
{ if ($0 == name) { print NR-1; found=1; exit } } if [ -n "$_fe_n" ]; then
END { if (!found) print -1 } printf '%d' $((_fe_n - 1))
' else
printf '%d' -1
fi
} }
# is entry name in SELECTED list? # is entry name in SELECTED list?
is_selected() { is_selected() {
case " printf '%s\n' "$SELECTED" | grep -Fxq "$1"
${SELECTED}
" in *"
$1
"*) return 0 ;; esac
return 1
} }
count_selected() { count_selected() {
@@ -359,16 +321,22 @@ render_row() {
entry=$(get_entry "$_idx") entry=$(get_entry "$_idx")
case "$entry" in case "$entry" in
*/) colour="${GREEN}${BOLD}" ;; */) colour="${BLUE}${BOLD}" ;;
*@) *@)
_name="${entry%@}" _name="${entry%@}"
# broken symlink if target doesn't exist
if [ -e "${CWD}/${_name}" ]; then if [ -e "${CWD}/${_name}" ]; then
colour="${MAGENTA}${BOLD}" colour="${CYAN}${BOLD}"
else else
colour="${RED}${BOLD}" colour="${RED}${BOLD}"
fi ;; 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 esac
# multi-select marker # multi-select marker
@@ -400,17 +368,21 @@ render_row() {
if [ "$SHOW_DETAILS" = "1" ]; then if [ "$SHOW_DETAILS" = "1" ]; then
_path="${CWD}/${entry%/}"; _path="${_path%@}" _path="${CWD}/${entry%/}"; _path="${_path%@}"
_info=$(ls -ldh "$_path" 2>/dev/null) _info=$(ls -ldh "$_path" 2>/dev/null)
_sz=$(printf '%s' "$_info" | awk '{print $5}') # parse ls output with parameter expansion — no awk fork
_dt=$(printf '%s' "$_info" | awk '{print $6, $7}') set -- $_info
# fixed width: size=6, date=6 → total detail = 14 chars _sz=$5; _dt="$6 $7"
_detail=$(printf ' %6s %-6s' "$_sz" "$_dt") _detail=$(printf ' %6s %-6s' "$_sz" "$_dt")
set --
fi fi
# layout: marker(1) + name + spaces + detail # layout: marker(1) + name + spaces + detail
_dlen=${#_detail} _dlen=${#_detail}
maxw=$((_cols - 1 - _dlen - 1)) maxw=$((_cols - 2 - _dlen))
if [ "${#display}" -gt "$maxw" ]; then 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}..." display="${display}..."
fi fi
_namew=$((${#display} + 1)) # 1 for marker _namew=$((${#display} + 1)) # 1 for marker
@@ -468,7 +440,7 @@ draw_preview() {
_dl=" ${_dn}/" _dl=" ${_dn}/"
[ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~" [ "${#_dl}" -gt "$((_pw - 1))" ] && _dl="$(printf '%s' "$_dl" | cut -c1-$((_pw-2)))~"
goto "$_pr" "$_px" goto "$_pr" "$_px"
printf '%s%s%s' "${GREEN}${BOLD}" "$_dl" "${RESET}" printf '%s%s%s' "${BLUE}${BOLD}" "$_dl" "${RESET}"
_pr=$((_pr + 1)) _pr=$((_pr + 1))
done done
for _fe in "$_path"/*; do for _fe in "$_path"/*; do
@@ -681,15 +653,21 @@ draw() {
read_key() { read_key() {
IFS= read -r -n1 key 2>/dev/null || IFS= read -r key IFS= read -r -n1 key 2>/dev/null || IFS= read -r key
if [ "$key" = "$(printf '\033')" ]; then 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="" IFS= read -r -n1 -t 0.1 _k2 2>/dev/null || _k2=""
if [ -z "$_k2" ]; then if [ -z "$_k2" ]; then
# nothing followed — bare esc
printf '\033'; return printf '\033'; return
fi 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="" 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}" key="${key}${_k2}${_k3}"
;;
esac
fi fi
printf '%s' "$key" printf '%s' "$key"
} }
@@ -799,8 +777,10 @@ open_file() {
# read a line of input in raw mode, returns result in READ_LINE # read a line of input in raw mode, returns result in READ_LINE
# returns 1 if user pressed Esc (cancel), 0 on Enter # returns 1 if user pressed Esc (cancel), 0 on Enter
read_line() { read_line() {
READ_LINE="" READ_LINE="${_init:-}"
_init=""
show_cursor show_cursor
printf '%s' "$READ_LINE"
while true; do while true; do
IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch IFS= read -r -n1 _ch 2>/dev/null || IFS= read -r _ch
case "$_ch" in case "$_ch" in
@@ -855,7 +835,7 @@ do_open_with() {
do_newfile() { do_newfile() {
goto "$(term_rows)" 1 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 if read_line && [ -n "$READ_LINE" ]; then
touch "${CWD}/${READ_LINE}" touch "${CWD}/${READ_LINE}"
load_entries load_entries
@@ -891,7 +871,7 @@ do_help() {
bw=$((COLS - 4)) bw=$((COLS - 4))
[ "$bw" -gt 52 ] && bw=52 [ "$bw" -gt 52 ] && bw=52
[ "$bw" -lt 36 ] && bw=36 [ "$bw" -lt 36 ] && bw=36
bh=42 bh=46
_help_bx=$(( (COLS - bw) / 2 )); [ "$_help_bx" -lt 1 ] && _help_bx=1 _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_by=$(( (ROWS - bh) / 2 )); [ "$_help_by" -lt 1 ] && _help_by=1
_help_iw=$((bw - 2)) _help_iw=$((bw - 2))
@@ -929,17 +909,21 @@ do_help() {
help_row 27 " U open trash directory" help_row 27 " U open trash directory"
help_row 28 " ! drop to shell in CWD" help_row 28 " ! drop to shell in CWD"
help_row 29 " o open with custom program" help_row 29 " o open with custom program"
help_sep 30 help_row 30 " + chmod +x (make executable)"
help_row 31 " b bookmark current dir" help_row 31 " - chmod -x (remove executable)"
help_row 32 " B open bookmark picker" help_sep 32
help_row 33 " c copy path to clipboard" help_row 33 " b bookmark current dir"
help_row 34 " ~ go to home directory" help_row 34 " B open bookmark picker"
help_row 35 " \` jump to previous directory" help_row 35 " c copy path to clipboard"
help_sep 36 help_row 36 " ~ go to home directory"
help_row 37 " q quit" help_row 37 " \` jump to previous directory"
help_row 38 " ? this help" help_row 38 " : jump to path"
help_row 39 "" help_row 39 " f find files recursively"
help_row 40 " press any key to close..." 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" goto "$(( _help_by + bh ))" "$_help_bx"
printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_help_hl" "${RESET}"
@@ -1020,6 +1004,141 @@ do_toggle_preview() {
NEED_FULL_REDRAW=1 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() { do_go_back() {
[ "$CWD" = "/" ] && return [ "$CWD" = "/" ] && return
FILTER="" FILTER=""
@@ -1097,19 +1216,93 @@ do_open() {
esac 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() { do_delete() {
INFO_MSG="" INFO_MSG=""
_c=$(count_selected) _c=$(count_selected)
if [ "$_c" -gt 0 ]; then if [ "$_c" -gt 0 ]; then
# --- bulk delete selected --- # --- bulk delete selected ---
restore_term; show_cursor _prompt="Delete ${_c} selected items?"
goto "$(term_rows)" 1 confirm_overlay; _yes=$?
printf '%s%s Delete %d selected items? [y/N] %s' \ if [ "$_yes" -eq 0 ]; then
"${ERASE_LINE}" "${RED}${BOLD}" "$_c" "${RESET}"
IFS= read -r ans
setup_term; hide_cursor
case "$ans" in
y|Y)
_rest="$SELECTED" _rest="$SELECTED"
while [ -n "$_rest" ]; do while [ -n "$_rest" ]; do
_line="${_rest%% _line="${_rest%%
@@ -1119,60 +1312,53 @@ do_delete() {
[ "$_next" = "$_rest" ] && _next="" [ "$_next" = "$_rest" ] && _next=""
_rest="$_next" _rest="$_next"
[ -z "$_line" ] && continue [ -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 if [ -d "$_t" ]; then rm -rf "$_t"; else rm -f "$_t"; fi
done done
SELECTED="" SELECTED=""
[ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
[ "$SEL" -lt 0 ] && SEL=0 [ "$SEL" -lt 0 ] && SEL=0
load_entries load_entries
;; else
*) NEED_FULL_REDRAW=1 ;; NEED_FULL_REDRAW=1
esac fi
else else
# --- single delete --- # --- single delete ---
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
target="${CWD}/${entry%/}" _name="${entry%@}"; _name="${_name%/}"
restore_term; show_cursor target="${CWD}/${_name}"
goto "$(term_rows)" 1 _prompt="Delete \"${entry}\"?"
printf '%s%s Delete "%s"? [y/N] %s' "${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" confirm_overlay; _yes=$?
IFS= read -r ans if [ "$_yes" -ne 0 ]; then
setup_term; hide_cursor NEED_FULL_REDRAW=1; return
case "$ans" in fi
y|Y)
if [ -d "$target" ]; then if [ -d "$target" ]; then
if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then if [ -n "$(ls -A "$target" 2>/dev/null)" ]; then
restore_term; show_cursor _prompt="\"${entry}\" not empty. Delete ALL?"
goto "$(term_rows)" 1 confirm_overlay; _yes2=$?
printf '%s%s "%s" not empty. Delete ALL? [y/N] %s' \ if [ "$_yes2" -ne 0 ]; then
"${ERASE_LINE}" "${RED}${BOLD}" "$entry" "${RESET}" NEED_FULL_REDRAW=1; return
IFS= read -r ans2
setup_term; hide_cursor
case "$ans2" in
y|Y) rm -rf "$target" ;;
*) NEED_FULL_REDRAW=1; return ;;
esac
else
rm -rf "$target"
fi fi
fi
rm -rf "$target"
else else
rm -f "$target" rm -f "$target"
fi fi
[ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
[ "$SEL" -lt 0 ] && SEL=0 [ "$SEL" -lt 0 ] && SEL=0
load_entries load_entries
;;
*) NEED_FULL_REDRAW=1 ;;
esac
fi fi
} }
do_rename() { do_rename() {
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
_name="${entry%@}"; _name="${_name%/}"
goto "$(term_rows)" 1 goto "$(term_rows)" 1
printf '%s%s Rename "%s" to (esc=cancel): %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "$entry" "${RESET}" printf '%s%s Rename: %s' "${ERASE_LINE}" "${YELLOW}${BOLD}" "${RESET}"
if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$entry" ]; then _init="$_name"
mv "${CWD}/${entry%/}" "${CWD}/${READ_LINE}" if read_line && [ -n "$READ_LINE" ] && [ "$READ_LINE" != "$_name" ]; then
mv "${CWD}/${_name}" "${CWD}/${READ_LINE}"
load_entries load_entries
else else
NEED_FULL_REDRAW=1; draw NEED_FULL_REDRAW=1; draw
@@ -1181,7 +1367,7 @@ do_rename() {
do_mkdir() { do_mkdir() {
goto "$(term_rows)" 1 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 if read_line && [ -n "$READ_LINE" ]; then
mkdir -p "${CWD}/${READ_LINE}" mkdir -p "${CWD}/${READ_LINE}"
load_entries load_entries
@@ -1190,18 +1376,125 @@ do_mkdir() {
fi 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() { do_info() {
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
[ -z "$entry" ] && return [ -z "$entry" ] && return
target="${CWD}/${entry%/}" _name="${entry%@}"; _name="${_name%/}"
# permissions + type target="${CWD}/${_name}"
_perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}')
# size (human readable via du, fallback to ls) # gather info
_size=$(du -sh "$target" 2>/dev/null | cut -f1) _info_perm=$(ls -ld "$target" 2>/dev/null | awk '{print $1}')
[ -z "$_size" ] && _size=$(ls -lh "$target" 2>/dev/null | awk '{print $5}') _info_owner=$(ls -ld "$target" 2>/dev/null | awk '{print $3}')
# modification date _info_group=$(ls -ld "$target" 2>/dev/null | awk '{print $4}')
_date=$(ls -ld "$target" 2>/dev/null | awk '{print $6, $7, $8}') _info_size=$(du -sh "$target" 2>/dev/null | cut -f1)
INFO_MSG="${_perm} ${_size} ${_date}" [ -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 NEED_FULL_REDRAW=1
} }
@@ -1231,9 +1524,10 @@ do_sort() {
do_trash() { do_trash() {
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
[ -z "$entry" ] && return [ -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') _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 if mv "$target" "$_dest"; then
INFO_MSG="trashed: ${entry}" INFO_MSG="trashed: ${entry}"
[ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2)) [ "$SEL" -ge "$((COUNT - 1))" ] && SEL=$((COUNT - 2))
@@ -1256,7 +1550,8 @@ do_open_trash() {
do_copy_path() { do_copy_path() {
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
[ -z "$entry" ] && return [ -z "$entry" ] && return
_path="${CWD}/${entry%/}" _name="${entry%@}"; _name="${_name%/}"
_path="${CWD}/${_name}"
if command -v wl-copy >/dev/null 2>&1; then if command -v wl-copy >/dev/null 2>&1; then
printf '%s' "$_path" | wl-copy printf '%s' "$_path" | wl-copy
elif command -v xclip >/dev/null 2>&1; then elif command -v xclip >/dev/null 2>&1; then
@@ -1314,6 +1609,8 @@ do_bookmark_jump() {
_hl=$(printf '%*s' "$_iw" '' | tr ' ' '-') _hl=$(printf '%*s' "$_iw" '' | tr ' ' '-')
_bsel=1 _bsel=1
_bvis=$((_ph - 3))
_boff=1
while true; do while true; do
# draw picker inline # draw picker inline
goto "$_py" "$_px" 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}" printf '%s|%s%s%s%s|%s' "${BOLD}${CYAN}" "${RESET}" "$_t" "$_p" "${BOLD}${CYAN}" "${RESET}"
goto "$((_py+2))" "$_px" goto "$((_py+2))" "$_px"
printf '%s|%s|%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" 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 _bi=0
while IFS= read -r _bm; do while IFS= read -r _bm; do
[ -z "$_bm" ] && continue [ -z "$_bm" ] && continue
_bi=$((_bi+1)) _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}" _bt="${_bi} ${_bm}"
if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi if [ "${#_bt}" -gt "$_iw" ]; then _bt=$(printf '%s' "$_bt" | cut -c1-$((_iw-1))); _bt="${_bt}~"; fi
_bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '') _bpl=$((_iw - ${#_bt})); _bp=$(printf '%*s' "$_bpl" '')
@@ -1341,14 +1650,18 @@ do_bookmark_jump() {
printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}" printf '%s+%s+%s' "${BOLD}${CYAN}" "$_hl" "${RESET}"
IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk IFS= read -r -n1 _bk 2>/dev/null || IFS= read -r _bk
case "$_bk" in case "$_bk" in
j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; j) _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc
k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; [ "$_bsel" -ge $((_boff + _bvis)) ] && _boff=$((_bsel - _bvis + 1)) ;;
k) _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1
[ "$_bsel" -lt "$_boff" ] && _boff=$_bsel ;;
"$(printf '\033')") "$(printf '\033')")
# use timeout to distinguish bare esc from arrow sequences # use timeout to distinguish bare esc from arrow sequences
IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq="" IFS= read -r -n2 -t 0.1 _seq 2>/dev/null || _seq=""
case "$_seq" in case "$_seq" in
'[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1 ;; '[A') _bsel=$((_bsel-1)); [ "$_bsel" -lt 1 ] && _bsel=1
'[B') _bsel=$((_bsel+1)); [ "$_bsel" -gt "$_bc" ] && _bsel=$_bc ;; [ "$_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 '[C') # right — open
_chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE") _chosen=$(awk -v n="$_bsel" 'NR==n&&NF{print;exit}' "$BOOKMARK_FILE")
if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then if [ -n "$_chosen" ] && [ -d "$_chosen" ]; then
@@ -1424,7 +1737,8 @@ do_yank() {
if [ "$_c" -gt 0 ]; then if [ "$_c" -gt 0 ]; then
CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
[ -z "$_e" ] && continue [ -z "$_e" ] && continue
printf '%s\n' "${CWD}/${_e%/}" _yname="${_e%@}"; _yname="${_yname%/}"
printf '%s\n' "${CWD}/${_yname}"
done) done)
CLIP_MODE="copy" CLIP_MODE="copy"
INFO_MSG="yanked ${_c} items" INFO_MSG="yanked ${_c} items"
@@ -1432,7 +1746,8 @@ do_yank() {
else else
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
[ -z "$entry" ] && return [ -z "$entry" ] && return
CLIPBOARD="${CWD}/${entry%/}" _yname="${entry%@}"; _yname="${_yname%/}"
CLIPBOARD="${CWD}/${_yname}"
CLIP_MODE="copy" CLIP_MODE="copy"
INFO_MSG="yanked: ${entry}" INFO_MSG="yanked: ${entry}"
fi fi
@@ -1444,7 +1759,8 @@ do_cut() {
if [ "$_c" -gt 0 ]; then if [ "$_c" -gt 0 ]; then
CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do CLIPBOARD=$(printf '%s\n' "$SELECTED" | while IFS= read -r _e; do
[ -z "$_e" ] && continue [ -z "$_e" ] && continue
printf '%s\n' "${CWD}/${_e%/}" _cname="${_e%@}"; _cname="${_cname%/}"
printf '%s\n' "${CWD}/${_cname}"
done) done)
CLIP_MODE="cut" CLIP_MODE="cut"
INFO_MSG="cut ${_c} items" INFO_MSG="cut ${_c} items"
@@ -1452,7 +1768,8 @@ do_cut() {
else else
entry=$(get_entry "$SEL") entry=$(get_entry "$SEL")
[ -z "$entry" ] && return [ -z "$entry" ] && return
CLIPBOARD="${CWD}/${entry%/}" _cname="${entry%@}"; _cname="${_cname%/}"
CLIPBOARD="${CWD}/${_cname}"
CLIP_MODE="cut" CLIP_MODE="cut"
INFO_MSG="cut: ${entry}" INFO_MSG="cut: ${entry}"
fi fi
@@ -1465,7 +1782,6 @@ do_paste() {
NEED_FULL_REDRAW=1 NEED_FULL_REDRAW=1
return return
fi fi
_ok=0; _fail=0
printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do printf '%s\n' "$CLIPBOARD" | while IFS= read -r _src; do
[ -z "$_src" ] && continue [ -z "$_src" ] && continue
_name="${_src##*/}" _name="${_src##*/}"
@@ -1476,8 +1792,8 @@ do_paste() {
_dest="${CWD}/${_base}_copy${_ext}" _dest="${CWD}/${_base}_copy${_ext}"
fi fi
case "$CLIP_MODE" in case "$CLIP_MODE" in
copy) cp -r "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; copy) cp -r "$_src" "$_dest" ;;
cut) mv "$_src" "$_dest" && _ok=$((_ok+1)) || _fail=$((_fail+1)) ;; cut) mv "$_src" "$_dest" ;;
esac esac
done done
CLIPBOARD=""; CLIP_MODE="" CLIPBOARD=""; CLIP_MODE=""
@@ -1486,7 +1802,7 @@ do_paste() {
} }
# --- main --- # --- 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 setup_term
hide_cursor hide_cursor
@@ -1506,6 +1822,7 @@ while true; do
G) SEL=$((COUNT - 1)) ;; G) SEL=$((COUNT - 1)) ;;
"$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;; "$(printf '\033[6~')") SEL=$((SEL + $(term_rows) / 2)) ;;
"$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;; "$(printf '\033[5~')") SEL=$((SEL - $(term_rows) / 2)) ;;
"$(printf '\033[3~')") do_delete ;;
"$(printf '\n')"|\ "$(printf '\n')"|\
"$(printf '\r')"|\ "$(printf '\r')"|\
"$(printf '\033[C')"|\ "$(printf '\033[C')"|\
@@ -1521,12 +1838,14 @@ while true; do
T) do_toggle_details ;; T) do_toggle_details ;;
P) do_toggle_preview ;; P) do_toggle_preview ;;
i) do_info ;; i) do_info ;;
'!') do_shell ;; '+') do_chmod_x ;;
'-') do_chmod_nox ;;
o) do_open_with ;; o) do_open_with ;;
s) do_sort ;; s) do_sort ;;
u) do_trash ;; u) do_trash ;;
U) do_open_trash ;; U) do_open_trash ;;
'`') do_jump_back ;; f) do_find ;;
':') do_jump_path ;;
'~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED="" '~') PREV_CWD="$CWD"; CWD=$(cd "$HOME" && pwd); FILTER=""; SELECTED=""
SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;; SEL=0; OFFSET=0; PREV_SEL=0; PREV_OFFSET=0; load_entries ;;
c) do_copy_path ;; c) do_copy_path ;;
@@ -1541,6 +1860,7 @@ while true; do
m) do_mkdir ;; m) do_mkdir ;;
n) do_newfile ;; n) do_newfile ;;
q|Q) break ;; q|Q) break ;;
'!') do_shell ;;
esac esac
done done