Skip to main content

Using broot as an fzf-like path completer and interactive folder jumper in Zsh

·3534 words·17 mins
broot zsh
Table of Contents

Broot is a great file manager TUI which can be adapted for very specific workflows. But it takes some configuration up front to get things going, even for the basics.

I’ll go over some of my own configurations for using it from Zsh:

My dotfiles containing the snippets in this post are on GitHub at dotfiles-broot and dotfiles-zsh/broot.zsh.

If you already use broot, skip to Drill down. To follow along, go ahead and install broot first.

Demo videos
#

Drill down to change folder
#

Complete a partially typed argument with a path filter
#

Complete with a file-content filter
#

Wrap broot in a Zsh function
#

broot is compiled (from Rust). Since it’s not a shell function, it can’t simply change your shell state – in particular, change the current working directory. That’s best done from within the shell. Broot’s solution is:

  • launch broot via a small shell function
  • broot writes a temporary file with content like cd deep/down/folder
  • the shell function then evals the content of the tempfile

The simple way
#

If you launch broot in Zsh, it’ll ask to add a line to your .zshrc, sourcing a file containing the shell function, which you can also print out with:

$ broot --print-shell-function zsh
function br {
    local cmd cmd_file code
    cmd_file=$(mktemp)
    if broot --outcmd "$cmd_file" "$@"; then
        cmd=$(<"$cmd_file")
        command rm -f "$cmd_file"
        eval "$cmd"
    else
        code=$?
        command rm -f "$cmd_file"
        return "$code"
    fi
}

So you can just launch broot, let it add that function, and move on to the next section. Quit with ctrl-q.

A Zsh (or control) enthusiast’s way
#

But I don’t like tools editing my .zshrc, and I think Zsh syntax has an irresistible beauty, so I defined this similar function instead:

# -- Run broot, eval cmdfile if successful --
br () {  # [<broot-opt>...]
  emulate -L zsh

  local cmdfile=$(mktemp)
  trap "rm ${(q-)cmdfile}" EXIT INT QUIT
  if { broot --outcmd "$cmdfile" $@ } {
    if [[ -r $cmdfile ]]  eval "$(<$cmdfile)"
  } else {
    return
  }
}

To stop broot from asking to install its own br function, run:

$ broot --set-install-state installed

Room for improvement
#

While writing this post I remembered that eval is overkill for the task, given that the cmdfile only ever contains a cd command. I’ll add a bonus section at the end of this post, rewriting br to avoid eval.

Launch the wrapper with a non-disruptive shortcut
#

There are other ways to do this, but my approach is:

  • push whatever’s currently on the line out of the way
  • put br on the line and run it
  • when br exits, restore whatever was initially on the line

This is accomplished with a Zsh Line Editor ( ZLE) function. ZLE functions can do some extra things, like manipulate your shell’s current input line (BUFFER), and call other ZLE functions.

I name this one .zle_broot, and register it with zle -N:

# -- Clear line, run broot, restore line --
# Key: ctrl+b
# Depends: br
.zle_broot () {
  zle .push-input
  BUFFER="br"
  zle .accept-line
}
zle -N       .zle_broot
bindkey '^b' .zle_broot  # ctrl+b

Basic broot configuration
#

The first time you launch it, some default configuration files are created for you, in Hjson format. You can learn about the possibilities by reading the comments therein, and the config docs.

So you’ve now got config.hjson, which imports a list of “verb” definitions from verbs.hjson.

These defaults are OK, so feel free to jump to Basic broot usage. Stick around for some configuration tips.

Flags
#

You can set default_flags in config.hjson to control which files and properties appear when you launch br. I’ve got:

# -- Do show: git-ignored files, git info, file sizes --
default_flags: --git-ignored --show-git-info --sizes

Verbs
#

Have a look at verbs.hjson, where you can define actions you might want to trigger, from either broot’s “internal” actions (e.g. back, line_down, toggle_preview, toggle_second_tree), or “external” commands, each with access to the verb arguments as parameters. A verb can alternatively be a “cmd” type, wherein you can provide a sequence of broot actions as if you typed them interactively.

Here are some handy examples that I use:

verbs:
[
  {
    key: alt-f
    internal: ":toggle_files"
  }
  {
    key: alt-s
    internal: ":toggle_sizes"
  }
  {
    invocation: mkcd {new_dir}
    cmd: ":mkdir {new_dir};:focus {new_dir}"
  }
  {
    invocation: rmdir
    external: rmdir {directory}
    leave_broot: false
  }
  {
    key: alt-up
    internal: ":up_tree"
  }
  {
    key: alt-left
    internal: ":back"
  }
  {
    key: alt-down
    invocation: go
    internal: ":focus"
  }
  {
    invocation: go {path}
    internal: ":focus {path}"
  }
  {
    shortcut: ~
    internal: ":focus ~"
  }
  {
    invocation: clip
    internal: ":copy_path"
    leave_broot: false
  }
  {
    invocation: dolphin
    external: dolphin {directory}
    leave_broot: false
  }
]

Basic broot usage
#

Typing within broot puts text into a buffer at the bottom of the screen, which is used to do two different things:

  • filter files and folders
  • trigger a verb, making parameters from the currently selected file available to it

If there is a filter, it has to come first.

Filters
#

As you type almost anything but space, slash, or colon, files and folders are fuzzy filtered by pathname.

But you can change this by prefixing your filter with /, c/, or cr/:

prefixfilter byfilter syntax
Nonepathfuzzy match
f/file namefuzzy match
/file nameregular expression
c/file contentliteral
cr/file contentregular expression

Suffix any regular expression with /i to make it case insensitive.

When the filter is empty, enter ? to see help for all this, and browse the built in verbs and descriptions.

Verbs
#

A space or colon marks the beginning of a verb. I like to use a space interactively, and a colon in saved verb definitions, for readability.

So a buffer might look like:

projname cd

or

oldfle mv new-file-name

Yes, oldfle (not oldfile), because it’s a fuzzy filter, not a filename.

Keys
#

Some useful default keyboard shortcuts to know:

  • alt+i: toggle display of git-ignored files
  • alt+h: toggle display of .hidden files
  • enter: if folder is focused: navigate into it within broot; otherwise open the focused file externally
  • alt+enter: exit broot and cd into the focused folder

Drill down
#

The setup here is that you’re at your prompt, maybe with a command already typed in full or part, and you want to quickly change folder, especially to a descendant of the current folder, then have your prompt restored.

We can already do this with the wrapper we wrote above:

  • hit ctrl+b
  • filter and navigate
  • hit alt+enter (or type out “ cd” in the broot buffer)

But we can streamline it for this case, using a new Zsh function which launches broot with an alternate verb configuration.

This alternate launcher will feature these differences:

  • only folders shown, no files
  • enter on a folder quits broot and changes folder
  • enter on a file quits broot and changes to file’s parent
  • no temporary cmdfile, since we know we want to cd; use stdout

Drill down verb definitions
#

We create a new config file with some overriding verb definitions to associate with the enter key.

Since we plan on using stdout to send the path to Zsh, these verbs use broot’s print_path internal.

We also redefine the verbs for alt+enter and the cd invocation to do the same thing.

select-folder.hjson:

verbs:
[
  {
    key: enter
    internal: ":print_path"
    apply_to: directory
  }
  {
    key: enter
    cmd: ":parent;:print_path"
    apply_to: file
  }
  {
    key: alt-enter
    invocation: cd
    internal: ":print_path"
    apply_to: directory
  }
  {
    key: alt-enter
    invocation: cd
    cmd: ":parent;:print_path"
    apply_to: file
  }
]

Overriding verb definitions via additional broot configs works well, but overriding default_flags is best done in the CLI invocation (in our drill down launcher function).

Drill down launcher
#

We create a function .zle_cd-broot in .zshrc, the core of which will be:

broot \
  --color yes \
  --git-ignored \
  --show-git-info \
  --only-folders \
  --conf ".../select-folder.hjson;.../conf.hjson"

I bind it to alt+down, to match other navigation bindings I have for Zsh (alt+up/left/right).

Some funny bits need explaining, but let’s see the whole thing:

# -- Folder Navigation: Down --
# Key: alt+down
# Depends: ~/.config/broot/select-folder.hjson
# Optional: .zle_redraw-prompt
.zle_cd-broot () {
  echoti rmkx
  cd "$(
    <$TTY broot --color yes --git-ignored --show-git-info --only-folders \
    --conf "${XDG_CONFIG_HOME:-${HOME}/.config}/broot/select-folder.hjson;${XDG_CONFIG_HOME:-${HOME}/.config}/broot/conf.hjson"
  )"
  if (( $+functions[.zle_redraw-prompt] ))  zle .zle_redraw-prompt
  if (( $+functions[_zsh_highlight]     ))  _zsh_highlight
}
zle -N            .zle_cd-broot
bindkey '^[[1;3B' .zle_cd-broot  # alt+down

.zle_redraw-prompt?
#

Your fancy Zsh prompt may rely on certain hooks being run after a change of your current working directory, and then need to get poked by the ZLE system to get redrawn.

I lifted and restyled a function to do just that from Zsh for Humans by Roman Perepelitsa, of Powerlevel10k fame.

# -- Refresh prompt, rerunning any hooks --
# Credit: Roman Perepelitsa
# Original: https://github.com/romkatv/zsh4humans/blob/v2/fn/-z4h-redraw-prompt
.zle_redraw-prompt () {
  for 1 ( chpwd $chpwd_functions precmd $precmd_functions ) {
    if (( $+functions[$1] ))  $1 &>/dev/null
  }
  zle .reset-prompt
  zle -R
}
zle -N .zle_redraw-prompt

Drop this in your .zshrc to ensure the prompt reflects your newly-changed working directory.

To skip the rest of the .zle_cd-broot explanations, jump to complete a file argument.

echoti rmkx?
#

Debian based systems usually have a bit like this in /etc/zsh/zshrc:

# Make sure the terminal is in application mode, when zle is
# active. Only then are the values from $terminfo valid.
zle-line-init () { ... smkx ... }
zle-line-finish () { ... rmkx ... }

This changes the handling of keyboard input whenever a ZLE function is run, and returns it to normal afterward.

In “application” mode, broot won’t recognize your keys properly.

echoti rmkx does the same as zle-line-finish above: it takes the shell back out of application mode.

<$TTY?
#

As the zshzle manual says:

The standard input of the function is redirected from /dev/null . . .

<$TTY redirects your TTY to stdin again, the way it usually is.

Without doing this, you may find broot unresponsive to your keyboard input.

_zsh_highlight?
#

This is only useful if you use fast-syntax-highlighting, and forces f-s-h to rescan and recolor the input line, now that it’s changed.

${XDG_CONFIG_HOME:-${HOME}/.config}?
#

Using :- described in the zshexpn manual, this expression will evaluate to the value of XDG_CONFIG_HOME if that’s set and non-empty. Otherwise, it will be ~/.config.

Insert or complete paths
#

OK, this is the big one.

Like last time, we’ll use a set of alternate verb definitions, and a new launcher function.

But this launcher has a lot more work to do.

File selection verb definitions
#

These verbs will make it easier to print out one or more file paths.

All we’re going to do here is remap tab and enter.

If we have not added anything to the selection list, which would appear as a right-hand pane, hitting enter will now print the path of the focused file and quit.

We use the internal panel_right_no_open so that if there is a right-hand pane (housing selected files), enter will first switch pane-focus to that one, then apply the print_path action to each selected item.

Whereas tab usually triggers broot’s internal completion helper, we’ll now have it add the currently focused file to the list (“selected” or “marked”), then move focus down to the next path. This way, you can keep tapping tab to select consecutive file paths.

select.hjson:

verbs:
[
  {
    key: tab
    cmd: ":toggle_stage;:next_match"
  }
  {
    invocation: pright
    internal: panel_right_no_open
  }
  {
    key: enter
    cmd: ":pright;:print_path"
  }
]

We can’t use the panel_right_no_open internal directly in a cmd entry, as by default it has no “invocation” defined.

File selection launcher
#

I call it .zle_insert-path-broot, and bind it to ctrl+/.

Simple file selection launcher
#

.zle_insert-path-broot () {
  echoti rmkx
  local locations=(${(f)"$(
    <$TTY broot --color yes --conf "${HOME}/.config/broot/select.hjson;${HOME}/.config/broot/conf.hjson"
  )"})
  locations=(${(q-)locations})
  LBUFFER+="$locations "
}
zle -N       .zle_insert-path-broot
bindkey '^_' .zle_insert-path-broot  # ctrl+/

The (f) splits broot’s output into lines, which we store as an array. The (q-) adds shell quoting to any array item which needs it, before we flatten the array into a space-separated string we cram into LBUFFER, which corresponds to all text left of the cursor on our input line.

This is good enough to be useful, but it can be much better.

Complicated file selection launcher
#

We’ll improve on this by handling any already partially typed path, using it to:

  • guess a starting filter
  • guess a starting folder
  • guess whether we should see hidden files

At its core, the launcher will run:

broot \
  --color yes \
  --cmd "$broot_search" \
  $show_hidden \
  --no-sizes \
  --conf ".../select.hjson;.../conf.hjson" \
  $start_dir

It’s big, so let’s see the whole thing first, then take it piece by piece.

One known limitation is that it expects no newlines within your filenames.

# -- Complete current word as path using broot --
# Key: ctrl+/
# Depends: ~/.config/broot/select.hjson
.zle_insert-path-broot () {
  setopt localoptions extendedglob

  # If the current word is partially typed, grab it (partial),
  # pop it off the line, and create a search string for it (broot_search)
  local partial broot_search
  if [[ ${LBUFFER[-1]/ } ]] {

    # Store it (partial)
    partial=(${(z)LBUFFER})
    partial=${partial[-1]}

    # Remove it from the line
    LBUFFER=${LBUFFER[1,-$#partial-1]}

    # Expand home folder
    partial=${partial/#(\$HOME\/|\~\/)/~\/}

    # Store a sanitized version for broot search (broot_search)
    broot_search=${partial/#~\/}                 # strip leading '~/'
    broot_search=${broot_search//( |:|..\/|\;)}  # strip ' ', ':', '../', ';'
    broot_search=${broot_search//.\/}            # strip './'
    broot_search=${broot_search//\//\\/}         # replace '/' with '\/'

  }

  # If resulting search string starts with '.' or ends with '/.',
  # search hidden files (can be slow)
  local show_hidden=--no-hidden
  if [[ $broot_search[1] == . || $broot_search[-2,-1] == /. ]]  show_hidden=--hidden

  # If partial has leading '/', '../', or ~/,
  # set broot start folder (start_dir)
  local start_dir
  if [[ $partial == ~/* ]] {
    start_dir=~
  } else {
    local updirs=${(M)partial##(../|/)##}
    start_dir=${updirs:-.}
  }

  # Get a location (or not) from broot,
  # using select.hjson config
  echoti rmkx
  local locations=(${(f)"$(
    <$TTY broot --color yes --cmd "$broot_search" $show_hidden --no-sizes \
    --conf "${XDG_CONFIG_HOME:-${HOME}/.config}/broot/select.hjson;${XDG_CONFIG_HOME:-${HOME}/.config}/broot/conf.hjson" \
    $start_dir
  )"})

  # Add location to line or restore original partial
  if [[ $locations ]] {

    # If GNU coreutils realpath is present,
    # convert some paths from absolute to relative
    if (( $+commands[realpath] )) {
      if [[ $(realpath --help 2>&1) == *--relative-base* ]] {
        local locations_abs=($locations) loc
        locations=()
        for loc ( $locations_abs )  locations+=("$(realpath --relative-base ~ --relative-to . $loc)")
      }
    }

    # Ensure minimal and sufficient quoting
    locations=(${(q-)locations})

    LBUFFER+="$locations "
  } else {
    LBUFFER+=$partial
  }

  # If using fast-syntax-highlighting, retrigger it
  if (( $+functions[_zsh_highlight] ))  _zsh_highlight

}
zle -N       .zle_insert-path-broot
bindkey '^_' .zle_insert-path-broot  # ctrl+/

That’s it; if you don’t want or need any further explanation, I’ve got nothing more for you except the eval-avoiding br implementation bonus project.

Are we mid-word?
#

First, we determine if we’re mid-word on the input line. If not, we just need to insert whatever path broot spits out. But if so, we:

  • define variable partial – the unfinished path
  • define variable broot_search – a broot-friendly filter
  • remove the unfinished path from BUFFER, so we can insert a replacement
  # If the current word is partially typed, grab it (partial),
  # pop it off the line, and create a search string for it (broot_search)
  local partial broot_search
  if [[ ${LBUFFER[-1]/ } ]] {

This amounts to: “if there is a non-space character immediately to the left of the cursor,” or: “if we are mid-word.”

LBUFFER contains all input text left of the cursor. We get its last character. If it’s a space, we replace it with an empty string, which turns this whole condition false-y.

Set partial
#

We’re mid-word, so we split the LBUFFER content into an array of words, then store just the last word in partial.

    # Store it (partial)
    partial=(${(z)LBUFFER})
    partial=${partial[-1]}

Erase partial on the input line
#

    # Remove it from the line
    LBUFFER=${LBUFFER[1,-$#partial-1]}

$#partial” gives us the length of partial, so we know how much to trim off the end of LBUFFER.

Normalize leading user home paths
#

If the partially typed word starts with “$HOME/” or “~/”, we replace that bit with the full path of your home folder.

    # Expand home folder
    partial=${partial/#(\$HOME\/|\~\/)/~\/}

Set broot_search#

    # Store a sanitized version for broot search (broot_search)
    broot_search=${partial/#~\/}                 # strip leading '~/'
    broot_search=${broot_search//( |:|..\/|\;)}  # strip ' ', ':', '../', ';'
    broot_search=${broot_search//.\/}            # strip './'
    broot_search=${broot_search//\//\\/}         # replace '/' with '\/'

  }

To turn our partial path into a good broot filter string, we:

  • remove any leading home path
  • remove any of these substrings ' ', ':', '../', ';', './'
  • escape/quote any remaining forward slashes

Those forward slashes will match folder boundaries, so for example you can type:

$ head Co/actr/wk/aoc23

Then use broot to complete your line as:

$ head Code/factor-99/work/aoc-2023/day05/day05.factor

Set show_hidden
#

The idea is to spare you from hidden files unless you probably want them, as they add both visual noise and performance penalties, if there are many.

  # If resulting search string starts with '.' or ends with '/.',
  # search hidden files (can be slow)
  local show_hidden=--no-hidden
  if [[ $broot_search[1] == . || $broot_search[-2,-1] == /. ]]  show_hidden=--hidden

Set start_dir
#

We use partial to guess a good starting folder for the broot search.

  # If partial has leading '/', '../', or ~/,
  # set broot start folder (start_dir)
  local start_dir
  if [[ $partial == ~/* ]] {
    start_dir=~
  } else {
    local updirs=${(M)partial##(../|/)##}
    start_dir=${updirs:-.}
  }

If partial starts with our home, we use our home. Otherwise, we check for a leading substring of one or more ../ or /, and use that. If not, we use the current folder.

The core
#

The (f) is used to split the result on newlines, and we store the lines as an array.

  # Get a location (or not) from broot,
  # using select.hjson config
  echoti rmkx
  local locations=(${(f)"$(
    <$TTY broot --color yes --cmd "$broot_search" $show_hidden --no-sizes \
    --conf "${XDG_CONFIG_HOME:-${HOME}/.config}/broot/select.hjson;${XDG_CONFIG_HOME:-${HOME}/.config}/broot/conf.hjson" \
    $start_dir
  )"})

--no-sizes is a compromise for performance.

Insertion
#

  # Add location to line or restore original partial
  if [[ $locations ]] {

Final touches
#

We use realpath to turn some absolute paths to relative ones. Some realpath implementations don’t support this feature.

    # If GNU coreutils realpath is present,
    # convert some paths from absolute to relative
    if (( $+commands[realpath] )) {
      if [[ $(realpath --help 2>&1) == *--relative-base* ]] {
        local locations_abs=($locations) loc
        locations=()
        for loc ( $locations_abs )  locations+=("$(realpath --relative-base ~ --relative-to . $loc)")
      }
    }
    # Ensure minimal and sufficient quoting
    locations=(${(q-)locations})

The q- parameter expansion flag only quotes the items that need quoting, back on the input line.

    LBUFFER+="$locations "

Adding to LBUFFER also moves our CURSOR location for us, and any RBUFFER content is preserved.

  } else {
    LBUFFER+=$partial
  }

If locations ended up empty, we probably quit broot with ctrl+q, indicating a canceled operation. So we just put the almost-original partial back on the line as it was.

But if it had a leading ~/ or $HOME/, that’s now expanded. It’s a bug, but I can live with it.

  # If using fast-syntax-highlighting, retrigger it
  if (( $+functions[_zsh_highlight] ))  _zsh_highlight

As we saw earlier, this last bit is only helpful if you use fast-syntax-highlighting, and forces f-s-h to rescan and recolor the input line, now that it’s changed.

Bonus: Rewrite br to avoid eval
#

This method only works with broot>1.33.1.

Instead of --outcmd, we’ll use a new option, --verb-output, and a new “internal,” write_output.

First, we add a verb definition to our verbs.hjson like:

{
  key: alt-enter
  invocation: cd
  cmd: ":write_output {directory};:quit"
}

This is will write the focused folder path to a file (specified by --verb-output, coming up soon).

Now we can update our function to:

zmodload zsh/mapfile

# -- Run broot, cd into pathfile if successful --
# Depends: zmapfile
br () {  # [<broot-opt>...]
  emulate -L zsh

  local pathfile=$(mktemp)
  trap "rm ${(q-)pathfile}" EXIT INT QUIT
  if { broot --verb-output "$pathfile" $@ } {
    if [[ -r $pathfile ]] {
      local folder=${mapfile[$pathfile]}
      if [[ $folder ]]  cd $folder
    }
  } else {
    return
  }
}

It hasn’t changed much, but here we use --verb-output to tell broot what tempfile to write to, then cd into whatever path that file contains, if the file is written at all (and non-empty).

Additionally, a micro-optimization has been used to avoid a subshell; we could use

local folder=$(<$pathfile)

But instead we load the zsh/mapfile module, and access the file contents as the value in a magic dictionary, which maps file names to contents.

That’s all, folks!
#

That’s all I have to say about this for now, and if you’ve gotten this far, I hope some part of this was useful to you.

Did I make a mistake? Have I been unclear? Yell at me in the comments!