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 &amp; and < as &lt; and > as &gt; 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, mapfile and fractional read -t timeouts. Folders and files are tracked with parallel indexed arrays, and a sort pass 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 -t only accepts whole seconds, which would make Esc wait a full second. Instead, the script briefly sets the terminal to min 0 time 1 and reads the rest of the sequence with dd. 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 EXIT trap 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.

Leave a Reply