Simple File Explorer in 1 Line of Bash

Simple File Explorer in 1 Line of Bash

The idea is to build a simple tool that allows for quick exploration of the filesystem. The file list can be navigated using arrow keys and filtered with fuzzy-search. It's rather an overhauled cd tool than a real file explorer, but it served as a convenient example to show some useful shell workflow patterns. File explorer is a very generic concept. I believe it can be repurposed to accelerate many dull tasks.

Feel free to skip to the tl;dr section at the bottom if you are not interested in the gory details.

Fuzzy Finders

Aside from Bash or any other compatible shell we are going to need a fuzzy finder like fzf, skim or rofi. These are quite powerful tools and getting to know them is probably more valuable than any embarrassing piece of software we are going to get at the end of this recipe.

Each fuzzy finder provides a shell command that creates a UI with searchable list entries. Those entries are typically fed to its standard input, one line per each. A natural way to use them is through a use of standard anonymous pipes |. An example for fzf:

ls -l /bin | fzf

fzf UI

Here, I'm listing the content of my /bin directory. I've typed "rofi" to search for that phrase. Note that the matching substrings are highlighted and the list is adequately ordered. Upon pressing the enter key the exact selection is written to the standard output:

-rwxr-xr-x 1 root root   375816 2019-07-01  rofi

Ideally we would redirect this output to another command to perform a further action, e.g. change the directory. Unfortunately it's not that simple because not all commands read their input from the standard stream.

Capturing the Selection

We need a way to pass the selected line as a parameter. First we have to store it in a variable. We can use the shell substitution:

f=$(ls | fzf)           # or alternatively f=`ls | fzf`
f=$(ls | fzf) && cd "$f"  # chained command

2 things to note:

  1. I've dropped the -l flag from ls so the line contains only the name of the file
  2. I've used && to make sure that cd is called performed only if fzf succeeded, i.e. user didn't cancel the selection

The Loop

The main loop of the file explorer consists of following steps:

  1. List the content of the current directory
  2. Allow the user to make a selection
  3. cd into the selection and go to back to the 1. step

The syntax for the while loop in bash:

while condition
do
    cmd1
    cmd2
    ...
done

Most of the whitespace can be omitted or replaced with semicolons:

while condition; do cmd1; cmd2; done

A program returns an integer error code that is tested in bash's conditional statements. Note that it's not a value written to the standard output - after all you don't see an integer being printed after every command invocation! The condition tests positive if this return code equals 0. This is in contrary to the intuition from most other programming languages where it is reversed.

Any fuzzy finder returns the error code 0 if the user performed the selection. It most likely returns some other code if the program crashed or user cancelled the selection. Selection can be cancelled with <ESC> or <CTRL+C> in fzf. It is a good place to break out of our main loop. Without further ado:

while f=$(ls | fzf); do cd "$f"; done

The phrase f=$(ls | fzf) displays to content of the current directory and captures the user's selection to the variable $f. If the selection is cancelled the while condition fails and the procedure exits. Otherwise we cd into the selected directory and go back to the beginning.

Polishing

Our explorer already allows us to quickly cd into deeply nested directory but it's far from perfect. Let's address the most obvious issues. Note that at some point the code will stop fitting into a single line, but I'll keep trying to do so for the sake of this joke.

Transitioning to the Parent Directory

This sounds like an extremely important feature. We need to add .. to the list of possible selections. Let's simply prepend this entry manually. fzf input (and ls output) entries are separated by a newline character \n (i.e. there is one entry per line).

entries=".."$'\n'"$(ls)"
f=$(echo "$entries" | fzf)

# in main the loop
while entries=".."$'\n'"$(ls)"; f=$(echo "$entries" | fzf); do cd "$f"; done

# sanely reformatted
while entries=".."$'\n'"$(ls)" \
      f=$(echo "$entries" | fzf)
do
  cd "$f"
done

Opening Regular Files

There is no way to cd into regular files, so why are we even allowing them to be selected? Let's instead open a file with an appropriate program. On Linux there is usually a tool called xdg-open that automatically chooses the right program based on the file extension. We'd like to use this program only if the cd command failed. Just like many other programming languages, Bash's "logical or" operator || guarantees that the second statement is evaluated only if the previous one failed. This allows us to avoid complicate conditional statements:

cd "$f" || xdg-open "$f"

# in the loop
while entries=".."$'\n'"$(ls)"; f=$(echo "$entries" | fzf); do cd "$f" || xdg-open "$f"; done

# formatted
while entries=".."$'\n'"$(ls)" \
      f=$(echo "$entries" | fzf)
do
  cd "$f" || xdg-open "$f"
done

That was simple enough. Note that we've been using an analogous property of the "logical and" operator && in some of the previous snippets.

Suppressing the Error Messages

fzf does a good job at ensuring that your screen stays clean while you use it, but all of the error messages are still waiting for you when you finish the interaction. They are mostly harmless:

ls: cannot access 'backups': Permission denied
cd: not a directory: test.txt

(there might be also some logs from the GUI programs opened by `xdg-open`)

I'm going to leave the ls warnings since they are possibly useful but we can not afford the others. Let's redirect both standard and error output of those command to /dev/null so they wont bother anyone.

xdg-open "$f" >/dev/null 2>/dev/null
#             ^stdout     ^stderr

# this is probably more idiomatic
xdg-open "$f" >/dev/null 2>&1 
# first redirect stdout to /dev/null
# then redirect stderr to whatever stdin is pointing
# the order of those switches doesn't actually matter

You can find a comprehensive explanation of this topic here. Now let's integrate:

while entries=".."$'\n'"$(ls)"; f=$(echo "$entries" | fzf); do cd "$f" 2>/dev/null || xdg-open "$f" >/dev/null 2>&1; done
# ^ the line is already broken on some screens

# formatted
while entries=".."$'\n'"$(ls)" \
      f=$(echo "$entries" | fzf)
do
  cd "$f" >/dev/null 2>&1 || xdg-open "$f" >/dev/null 2>&1 
done

The line length has already exceeded 100 characters so I guess that's where we have to stop.

What Now?

Make sure to run fzf --help or man fzf to see all of it's capabilities. You may also look for inspiration among the examples on the fzf wiki. I also suggest you to take a look at the alternatives: rofi and skim. Their base functionality is the same but they differ in some extra features.

One such feature, that our file explorer would likely benefit, is the possibility to set custom keybindings. For example selecting .. to go to the parent directory is OK, but it would be much easier to just press the left arrow or backspace. This can be achieved with fzf's --bind option. By the way, skim has that too, because they try to maintain some level of compatibility.

If you'd like to have your tool always available in your shell, you can define it as a function in your .bashrc, e.g.:

fuzzy-cd {
  while entries=".."$'\n'"$(ls)" \
        f=$(echo "$entries" | fzf)
  do
    cd "$f" >/dev/null 2>&1 || xdg-open "$f" >/dev/null 2>&1 
  done
}

# call it as a any other command
# you need to source your .bashrc or open a new bash instance first

Finally, it's great that we can quickly compose such powerful tools but please keep in mind that Bash is unfortunately quite ugly. If you ever happen to write some more ambitious program, do us all a favor and drop this ancient horror. fzf already has bindings for Go, Closure and Ruby and skim can be used a Rust library. If you are still out of luck with those two, then writing some custom IPC is still worth it in the long run. You can still use Bash for some glue code.


Trivia

  • You can try out another fuzzy-finder by replacing fzf with either sk (for skim) or rofi -dmenu.
  • rofi actually presents itself as a "window switcher, application launcher and dmenu replacement", e.g. rofi -show drun triggers the application launcher. rofi creates its own window so it doesn't require a terminal and can be conveniently bound to a global hotkey. It's very popular among users of less popular Linux window managers such as i3 or awesome.
  • We should probably exit on severe ls failures. They are indicated with -2 error code. Less serious error can be ignored, because it simply means that some of the listed files were not accessible.
  • Note that there is a difference between a Bash function defined in your .bashrc and an executable script file. Only the former can modify the state of your current shell's environment. The file explorer couldn't be used to change the directory of the current shell if used as a script.

tl;dr

Install fzf and paste this line into your terminal:

while entries=".."$'\n'"$(ls)"; f=$(echo "$entries" | fzf); do cd "$f" 2>/dev/null || xdg-open "$f" >/dev/null 2>&1; done

The line is probably broken in this snippet, but it fits my terminal :P.

Better formatting for easier read:

while entries=".."$'\n'"$(ls)"     # prepending ".." to the `ls` output for parent directory \
      f=$(echo "$entries" | fzf)  # `fzf` can be replaced with `rofi -dmenu`
do
  cd "$f" >/dev/null 2>&1 || xdg-open "$f" >/dev/null 2>&1
  # most of the noise is actually due to IO redirections
done