ff.sh: A Quiet, Interactive File Finder for macOS
If you’ve ever run sudo find / -name "something" on a modern Mac, you know the result: the match you wanted is buried under hundreds of Operation not permitted lines. macOS protects large parts of the file system (SIP, TCC-protected folders, sealed system volumes), and find prints an error for every one of them. Even as root, you get the noise.
ff.sh is a small bash script that fixes this. It searches the file system for a filename or pattern, hides the permission errors, and lets you browse the results in a full-screen terminal interface.
How it works
1. Quiet searching. The script runs find with stderr discarded, so only real matches are kept. It also skips /System/Volumes. On modern macOS the Data volume is firmlinked into /, so searching both would list every file twice. /dev and /proc are skipped as well.
2. Progress feedback. A full-disk search can take a while. The search runs in the background while a spinner and a live match count update in your terminal, so you can tell it’s still working.
3. Results collected into arrays. When the search finishes, the matches are sorted and grouped by folder into bash arrays. Nothing is printed line by line.
4. A full-screen browser. The script switches to the terminal’s alternate screen. The top half lists every folder that contains a match, with a count. The bottom half lists the matching items in the highlighted folder and updates as you move through the folders.
Press Esc and the full-screen view disappears. You’re back at your prompt with your scrollback untouched.
Installing it
Save the script below as ff.sh, then make it executable. You only need to do this once:
chmod +x ff.sh
Optionally, move it somewhere on your PATH so you can run it from anywhere:
sudo mv ff.sh /usr/local/bin/ff
#!/usr/bin/env bash # ff.sh - search the file system for a filename or pattern and browse the results. # # Usage: sudo ./ff.sh [-x] [start-dir] # # sudo ./ff.sh hosts # anything with "hosts" in the name, searched from / # sudo ./ff.sh '*.plist' /Library # glob pattern, searched under /Library # sudo ./ff.sh -x hosts # exact filename match only # # While searching, a spinner and running match count are shown. When the search # finishes, a full-screen browser opens: # top half folders that contain matches # bottom half matching items in the highlighted folder # # Keys: Up/Down (or k/j) move PgUp/PgDn page Home/End first/last # Tab switch between the folder pane and the file pane # Esc (or q) leave full screen and return to the console # # Matching is case-insensitive. A pattern with no wildcard (* ? [ ]) is treated # as a substring match unless -x is given. Permission errors are hidden. # # Works with the stock macOS bash 3.2. exact=0 if [ "$1" = "-x" ]; then exact=1 shift fi if [ -z "$1" ]; then echo "Usage: sudo $0 [-x] [start-dir]" >&2 exit 1 fi pattern="$1" start="${2:-/}" if [ ! -d "$start" ]; then echo "Start directory not found: $start" >&2 exit 1 fi if [ "$(id -u)" -ne 0 ]; then echo "Note: not running as root; some locations will be skipped. Use: sudo $0 ..." >&2 fi # Wrap plain text in wildcards unless -x was given or the user supplied a glob. glob="$pattern" if [ "$exact" -eq 0 ]; then case "$glob" in *[\*\?\[]*) ;; *) glob="*${glob}*" ;; esac fi # /System/Volumes/Data is firmlinked into / on modern macOS; skipping it avoids # listing every file twice. /dev and /proc are pointless to search. run_find() { find "$start" \ \( -path /System/Volumes -o -path /dev -o -path /proc \) -prune \ -o -iname "$glob" -print 2>/dev/null } # If output is piped or redirected, behave like a plain quiet find. if [ ! -t 1 ] || [ ! -t 0 ]; then run_find exit 0 fi tmp=$(mktemp -d "${TMPDIR:-/tmp}/ff.XXXXXX") || exit 1 raw="$tmp/raw" sorted="$tmp/sorted" find_pid="" saved_stty="" tui_on=0 cleanup() { if [ -n "$find_pid" ]; then kill "$find_pid" 2>/dev/null fi if [ "$tui_on" -eq 1 ]; then printf '\033[0m\033[?25h\033[?1049l' else printf '\033[?25h' >&2 fi if [ -n "$saved_stty" ]; then stty "$saved_stty" 2>/dev/null fi rm -rf "$tmp" } trap cleanup EXIT trap 'exit 130' INT TERM # ---------------------------------------------------------------- search phase run_find > "$raw" & find_pid=$! spin='|/-\' i=0 printf '\033[?25l' >&2 while kill -0 "$find_pid" 2>/dev/null; do n=$(wc -l < "$raw" | tr -d ' ') printf '\r\033[K%s Searching %s for "%s" ... %s found' \ "${spin:$((i % 4)):1}" "$start" "$pattern" "$n" >&2 i=$((i + 1)) sleep 0.1 done wait "$find_pid" 2>/dev/null find_pid="" printf '\r\033[K' >&2 if [ ! -s "$raw" ]; then echo "No matches found for \"$pattern\" under $start" >&2 exit 0 fi printf 'Organizing %s results...' "$(wc -l < "$raw" | tr -d ' ')" >&2 # --------------------------------------------------------------- build arrays tab=$'\t' awk '{ n = match($0, /\/[^\/]*$/) if (n == 0) { d = "."; f = $0 } else if (n == 1) { d = "/"; f = substr($0, 2) } else { d = substr($0, 1, n - 1); f = substr($0, n + 1) } print d "\t" f }' "$raw" | LC_ALL=C sort -t "$tab" -k1,1 -k2,2 > "$sorted" dirs=() # unique folders dcount=() # number of matches in each folder dstart=() # index into files[] where each folder's matches begin files=() # matching names, grouped by folder nd=-1 nf=0 last="" while IFS="$tab" read -r d f; do if [ "$nd" -lt 0 ] || [ "$d" != "$last" ]; then nd=$((nd + 1)) dirs[$nd]="$d" dstart[$nd]=$nf dcount[$nd]=0 last="$d" fi files[$nf]="$f" nf=$((nf + 1)) dcount[$nd]=$((dcount[$nd] + 1)) done < "$sorted" ndirs=$((nd + 1)) printf '\r\033[K' >&2 # ------------------------------------------------------------------------ TUI sel=0 # selected folder top_off=0 # first visible folder fsel=0 # selected file within the folder f_off=0 # first visible file within the folder focus=0 # 0 = folder pane, 1 = file pane T=10 B=10 draw() { local rows cols r idx s t mark i fcount path read -r rows cols < <(stty size </dev/tty 2>/dev/null) [ -z "$rows" ] && rows=24 [ -z "$cols" ] && cols=80 T=$(( (rows - 3) / 2 )) [ "$T" -lt 1 ] && T=1 B=$(( rows - T - 2 )) [ "$B" -lt 1 ] && B=1 fcount=${dcount[$sel]} path=${dirs[$sel]} # keep selections inside their scroll windows [ "$sel" -lt "$top_off" ] && top_off=$sel [ "$sel" -ge $((top_off + T)) ] && top_off=$((sel - T + 1)) [ "$fsel" -lt "$f_off" ] && f_off=$fsel [ "$fsel" -ge $((f_off + B)) ] && f_off=$((fsel - B + 1)) local buf="" # header (row 1) s=" ff \"$pattern\" | $nf items in $ndirs folders | folder $((sel + 1))/$ndirs | Tab: switch pane PgUp/PgDn Esc: exit" printf -v t '%-*.*s' "$cols" "$cols" "$s" buf="$buf"$'\033[1;1H\033[0m\033[7m'"$t"$'\033[0m' # folder pane (rows 2 .. T+1) for ((i = 0; i < T; i++)); do r=$((i + 2)) idx=$((top_off + i)) buf="$buf"$'\033['"$r"$';1H\033[K' if [ "$idx" -lt "$ndirs" ]; then if [ "$idx" -eq "$sel" ]; then mark="> "; else mark=" "; fi printf -v s '%s%5d %s' "$mark" "${dcount[$idx]}" "${dirs[$idx]}" printf -v t '%-*.*s' "$cols" "$cols" "$s" if [ "$idx" -eq "$sel" ]; then if [ "$focus" -eq 0 ]; then buf="$buf"$'\033[7m'"$t"$'\033[0m' else buf="$buf"$'\033[1m'"$t"$'\033[0m' fi else buf="$buf${s:0:$cols}" fi fi done # separator (row T+2) r=$((T + 2)) if [ "$focus" -eq 0 ]; then t="folders"; else t="files"; fi s="--- [$t focus] $fcount in $path " local fill=$((cols - ${#s})) if [ "$fill" -gt 0 ]; then printf -v t '%*s' "$fill" '' s="$s${t// /-}" fi printf -v t '%-*.*s' "$cols" "$cols" "$s" buf="$buf"$'\033['"$r"$';1H\033[K\033[1m'"$t"$'\033[0m' # file pane (rows T+3 .. rows) for ((i = 0; i < B; i++)); do r=$((T + 3 + i)) idx=$((f_off + i)) buf="$buf"$'\033['"$r"$';1H\033[K' if [ "$idx" -lt "$fcount" ]; then s=" ${files[$((dstart[sel] + idx))]}" if [ "$idx" -eq "$fsel" ] && [ "$focus" -eq 1 ]; then printf -v t '%-*.*s' "$cols" "$cols" "$s" buf="$buf"$'\033[7m'"$t"$'\033[0m' else buf="$buf${s:0:$cols}" fi fi done printf '%s' "$buf" } move() { local d=$1 n c if [ "$focus" -eq 0 ]; then n=$((sel + d)) [ "$n" -lt 0 ] && n=0 [ "$n" -ge "$ndirs" ] && n=$((ndirs - 1)) if [ "$n" -ne "$sel" ]; then sel=$n fsel=0 f_off=0 fi else c=${dcount[$sel]} n=$((fsel + d)) [ "$n" -lt 0 ] && n=0 [ "$n" -ge "$c" ] && n=$((c - 1)) fsel=$n fi } page() { # $1 = direction (+1/-1) if [ "$focus" -eq 0 ]; then move $(($1 * T)); else move $(($1 * B)); fi } # Read the remainder of an escape sequence. A short tty timeout lets us tell a # bare Esc key apart from an arrow key without waiting a full second (bash 3.2's # read -t only takes whole seconds). read_seq() { seq="" local c stty -icanon min 0 time 1 </dev/tty while [ ${#seq} -lt 5 ]; do c=$(dd bs=1 count=1 2>/dev/null </dev/tty) [ -z "$c" ] && break seq="$seq$c" case "$seq" in O) continue ;; \[*[A-Za-z~] | O?) break ;; esac done stty -icanon min 1 time 0 </dev/tty } saved_stty=$(stty -g) stty -echo -icanon min 1 time 0 tui_on=1 printf '\033[?1049h\033[?25l\033[2J' while true; do draw key="" IFS= read -rsn1 key </dev/tty || break case "$key" in $'\033') read_seq case "$seq" in "") break ;; '[A' | OA) move -1 ;; '[B' | OB) move 1 ;; '[5~') page -1 ;; '[6~') page 1 ;; '[H' | '[1~' | OH) move -1000000000 ;; '[F' | '[4~' | OF) move 1000000000 ;; esac ;; q | Q) break ;; k) move -1 ;; j) move 1 ;; $'\t') focus=$((1 - focus)) ;; esac done exit 0 |
.
Escape & as & and < as < and > as > inside the code block, or use a code-block in the visual editor instead. –>
Usage
sudo ./ff.sh hosts # any name containing "hosts", searched from /
sudo ./ff.sh '*.plist' /Library # glob pattern, limited to /Library
sudo ./ff.sh -x hosts # exact filename match only
Matching is case-insensitive. Plain text with no wildcards is treated as a substring match, and -x turns that off.
Controls
| Key | Action |
|---|---|
↑ / ↓ (or k / j) |
Move the selection |
| PgUp / PgDn | Move a page at a time |
| Home / End | Jump to the first or last item |
| Tab | Switch between the folder pane and the file pane |
Esc (or q) |
Exit and return to the console |
Implementation notes
A few details may interest other script writers:
- It runs on the stock macOS bash 3.2. That rules out associative arrays,
mapfileand fractionalread -ttimeouts. Folders and files are tracked with parallel indexed arrays, and asortpass groups the matches by folder so each folder can be a slice of one array. - Esc is told apart from arrow keys without lag. Arrow keys send escape sequences that start with the same byte as the Esc key. Bash 3.2’s
read -tonly accepts whole seconds, which would make Esc wait a full second. Instead, the script briefly sets the terminal tomin 0 time 1and reads the rest of the sequence withdd. A bare Esc is recognized in about 0.1 seconds. - The screen is drawn in one write. Each redraw is built into a single string with ANSI cursor-positioning codes and printed once, which avoids flicker.
- Cleanup is handled by a trap. An
EXITtrap restores the terminal, cursor and screen mode and deletes the temp files, even after Ctrl-C. - Piping still works. If stdout isn’t a terminal, for example
sudo ./ff.sh foo | grep bar, the script skips the interface and prints a plain quiet list.
A note on Full Disk Access
Even with sudo, macOS can block some locations, such as Mail or Photos data, unless your terminal app has Full Disk Access (System Settings → Privacy & Security → Full Disk Access). The script hides those errors, so if a file you expect is missing, check that setting.
And just in case you were wondering, the text above was created by AI as was the script. But it works amazingly well, give it a chance if you have the need.