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
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:
- I've dropped the
-l
flag fromls
so the line contains only the name of the file - I've used
&&
to make sure thatcd
is called performed only iffzf
succeeded, i.e. user didn't cancel the selection
The Loop
The main loop of the file explorer consists of following steps:
- List the content of the current directory
- Allow the user to make a selection
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 eithersk
(for skim) orrofi -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