r/bash • • 7d ago

fm: a single-file bash file manager — fzf picker, gallery grid, trash undo

Post image

I wrote a ~1500-line bash file manager. fzf for the picker, eza for listing, Kitty's graphics protocol for inline image/video/PDF previews. Everything else is stock coreutils.

![demo](https://raw.githubusercontent.com/eHab-codeX/fm/main/docs/demo.gif)

Repo: https://github.com/eHab-codeX/fm

Why post it here

r/commandline got the "here's a tool" version last week. The bash bits are probably more interesting to this crowd, so here they are.

The bash-specific parts

Double-click detection without EPOCHREALTIME getting in the way. EPOCHREALTIME returns a locale-formatted decimal — . in en_US, , in de_DE. Strip the separator before comparing:

now=${EPOCHREALTIME/[.,]/}
(( idx == last_idx && now - last_t < 400000 ))   # 400ms

A hand-rolled CSI/SS3 parser for the gallery grid. The full-screen grid needs arrow keys, PgUp/PgDn, Home/End, and SGR mouse events. Rather than depend on a helper, it reads bytes into a buffer and dispatches on the final byte:

case "$final" in
  A) KEY=up ;; B) KEY=down ;;
  C) KEY=right ;; D) KEY=left ;;
  '~') case "${seq%%;*}" in
         5) KEY=pgup ;; 6) KEY=pgdn ;;
       esac ;;
esac

The tricky part is not swallowing stray bytes: an unknown CSI sequence (\e[1;5H for Ctrl+Home, for example) has to be fully consumed or it leaks into the next read as literal characters.

**stty -g around the alternate screen.** The grid switches to cbreak mode (stty -icanon -echo min 1 time 0). Saving the previous state and restoring it in the EXIT trap is what keeps Ctrl-C from leaving the terminal in a broken state:

STTY_SAVED=$(stty -g </dev/tty) || STTY_SAVED=""
trap '[[ -n "$STTY_SAVED" ]] && stty "$STTY_SAVED" </dev/tty' EXIT

Two-phase rename so swaps work. A naive mv old new loop breaks a→b, b→a. First move every source to a temp name, then move temps to final names:

for k in "${!ro[@]}"; do
    mv -T -- "${ro[k]}" "${ro[k]}.fm-tmp.$$"
done
for k in "${!ro[@]}"; do
    mv -T -- "${ro[k]}.fm-tmp.$$" "${rn[k]}"
done

Parallel thumbnails by self-invocation. The thumbnail builder is the same script with --thumb-any:

printf '%s\0' "${slice[@]}" | \
    xargs -0 -r -n1 -P"$(nproc)" bash "$SELF" --thumb-any

Bash dispatches; ImageMagick/ffmpeg/pdftoppm do the work.

GNU vs BSD head. head -z is GNU-only. A contributor on macOS sent a PR that falls back to ghead, then to a pure-bash reader:

while ((count < $1)) && IFS= read -r -d '' record; do
    printf '%s\0' "$record"
    ((++count))
done

What I'd want reviewed

  • The escape parser in _g_readkey — there are terminals that emit sequences it might mis-handle (Konsole? older tmux?).
  • The _fm_list sort pipeline — it does one batched stat and one awk pass, then sort -V. I'm not 100% sure the tie-breaking behavior for identical timestamps is stable.
  • Anything in _trash_and_record — I'm handling the Freedesktop .trashinfo format's Path= percent-encoding with a bash-only encoder, which is probably slower than it should be.

Requirements

Bash 5+ (uses EPOCHREALTIME and ${var,,}). Linux is the primary target; macOS works with GNU coreutils installed (the _head0 helper falls back to bash-only if ghead isn't present).

git clone https://github.com/eHab-codeX/fm.git ~/.local/share/fm
ln -sf ~/.local/share/fm/fm ~/.local/bin/fm

MIT.

52 Upvotes

8 comments sorted by

4

u/eifelcode 7d ago

That’s a cool project and shows again how powerful bash is. I’ve taken a look into your source and well…. It looks a bit unstructured because everything is in a single file. I had the same problem in my project that I want a single file application written in bash but code structured in several files. I succeeded this by using a Make file. Take a look at pdfmt maybe you find some useful stuff to adapt to your project. Also tests and a CI pipeline would be cool. ☺️

3

u/Awkward_Process_9627 7d ago

Thanks — and yeah, you're right about the structure. It started small

and grew past the point where one file is comfortable.

The single-file shape was deliberate: "copy the file, symlink it, done"

is the entire install story, and it's what makes fm easy to carry

between machines. A Makefile that concatenates multiple .bash files

into the final `fm` breaks that — either users need `make`, or the

built file has to live in the repo, or it only ships in release

tarballs. All three options trade away simplicity for tidiness.

That said, I'm curious how pdfmt handles it. Do you commit the

concatenated output, generate it at release time, or something else?

If there's a pattern where the repo has clean sources and users still

get a single copy-paste file, I'd genuinely want to know.

On tests/CI: the repo already has a shellcheck workflow

(.github/workflows/lint.yml) that runs on every push. Real tests are

harder for a TUI that talks to the terminal — most of the interesting

paths involve alternate screens and mouse events, which don't replay

well. If you've got ideas for that, they'd be welcome.

Looked at pdfmt — nice work. Bookmarked to read through properly when

I have time.

3

u/anto77_butt_kinkier manpage, my beloved 7d ago

Hey so I hate to do this to you, but it looks like you tried to use markdown format in the rich text editor, which doesn't impliment the formatting.

You need to edit the post, switch to markdown mode, and remove the escape characters from in front of the markdown formatting characters.

1

u/Awkward_Process_9627 6d ago

Fixed — thanks for pointing it out. The rich text editor ate the

markdown and escaped all the formatting characters. Switched to

Markdown mode and re-pasted. Should render properly now.

2

u/anto77_butt_kinkier manpage, my beloved 6d ago

If you re-pasted, you may need to make sure there are no escape characters. For me it's still not showing as formatted, although idk if that's because I'm on mobile on really shitty wifi, and it just hasn't updated for me yet

Edit: nevermind, I refreshed the page and it now renders correctly.

1

u/Ok-Sample-8982 4d ago

Good project. But why you are not using “.” source?

Break that huge file into small chunks then source them where u need them in the main.

Ive written many tools on bash and my whole backend is running on pure bash on openwrt router. If i didnt use source it would have been a nightmare to maintain.