Advanced fzf examples

(Last update: 2021/05/22)

Introduction

fzf is an interactive Unix filter program that is designed to be used with other Unix tools. It reads a list of items from the standard input, allows you to select a subset of the items, and prints the selected ones to the standard output. You can think of it as an interactive version of grep, and it’s already useful even if you don’t know any of its options.

# 1. ps:   Feed the list of processes to fzf
# 2. fzf:  Interactively select a process using fuzzy matching algorithm
# 3. awk:  Take the PID from the selected line
# 3. kill: Kill the process with the PID
ps -ef | fzf | awk '{print $2}' | xargs kill -9

While the above example succinctly summarizes the fundamental concept of fzf, you can build much more sophisticated interactive workflows using fzf once you learn its wide variety of features.

This document will guide you through some examples that will familiarize you with the advanced features of fzf.

Screen Layout

--height

fzf by default opens in fullscreen mode, but it’s not always desirable. Oftentimes, you want to see the current context of the terminal while using fzf. --height is an option for opening fzf below the cursor in non-fullscreen mode so you can still see the previous commands and their results above it.

fzf --height=40%

You might also want to experiment with other layout options such as --layout=reverse, --info=inline, --border, --margin, etc.

fzf --height=40% --layout=reverse
fzf --height=40% --layout=reverse --info=inline
fzf --height=40% --layout=reverse --info=inline --border
fzf --height=40% --layout=reverse --info=inline --border --margin=1
fzf --height=40% --layout=reverse --info=inline --border --margin=1 --padding=1

(See Layout section of the man page to see the full list of options)

But you definitely don’t want to repeat --height=40% --layout=reverse --info=inline --border --margin=1 --padding=1 every time you use fzf. You could write a wrapper script or shell alias, but there is an easier option. Define $FZF_DEFAULT_OPTS like so:

export FZF_DEFAULT_OPTS="--height=40% --layout=reverse --info=inline --border --margin=1 --padding=1"

fzf-tmux

Before fzf had --height option, we would open fzf in a tmux split pane not to take up the whole screen. This is done using fzf-tmux script.

# Open fzf on a tmux split pane below the current pane.
# Takes the same set of options.
fzf-tmux --layout=reverse

The limitation of fzf-tmux is that it only works when you’re on tmux unlike --height option. But the advantage of it is that it’s more flexible. (See man fzf-tmux for available options.)

# On the right (50%)
fzf-tmux -r

# On the left (30%)
fzf-tmux -l30%

# Above the cursor
fzf-tmux -u30%

Popup window support

But here’s the really cool part; tmux 3.2 added support for popup windows. So you can open fzf in a popup window, which is quite useful if you frequently use split panes.

# Open tmux in a tmux popup window (default size: 50% of the screen)
fzf-tmux -p

# 80% width, 60% height
fzf-tmux -p 80%,60%

You might also want to check out my tmux plugins which support this popup window layout.

Dynamic reloading of the list

fzf can dynamically update the candidate list using an arbitrary program with reload bindings (The design document for reload can be found here).

Updating the list of processes by pressing CTRL-R

This example shows how you can set up a binding for dynamically updating the list without restarting fzf.

(date; ps -ef) |
  fzf --bind='ctrl-r:reload(date; ps -ef)' \
      --header=$'Press CTRL-R to reload\n\n' --header-lines=2 \
      --preview='echo {}' --preview-window=down,3,wrap \
      --layout=reverse --height=80% | awk '{print $2}' | xargs kill -9

Toggling between data sources

You’re not limited to just one reload binding. Set up multiple bindings so you can switch between data sources.

find * | fzf --prompt 'All> ' \
             --header 'CTRL-D: Directories / CTRL-F: Files' \
             --bind 'ctrl-d:change-prompt(Directories> )+reload(find * -type d)' \
             --bind 'ctrl-f:change-prompt(Files> )+reload(find * -type f)'

Ripgrep integration

Using fzf as the secondary filter

fzf is pretty fast for filtering a list that you will rarely have to think about its performance. But it is not the right tool for searching for text inside many large files, and in that case you should definitely use something like Ripgrep.

In the next example, Ripgrep is the primary filter that searches for the given text in files, and fzf is used as the secondary fuzzy filter that adds interactivity to the workflow. And we use bat to show the matching line in the preview window.

This is a bash script and it will not run as expected on other non-compliant shells. To avoid the compatibility issue, let’s save this snippet as a script file called rfv.

#!/usr/bin/env bash

# 1. Search for text in files using Ripgrep
# 2. Interactively narrow down the list using fzf
# 3. Open the file in Vim
IFS=: read -ra selected < <(
  rg --color=always --line-number --no-heading --smart-case "${*:-}" |
    fzf --ansi \
        --color "hl:-1:underline,hl+:-1:underline:reverse" \
        --delimiter : \
        --preview 'bat --color=always {1} --highlight-line {2}' \
        --preview-window 'up,60%,border-bottom,+{2}+3/3,~3'
)
[ -n "${selected[0]}" ] && vim "${selected[0]}" "+${selected[1]}"

And run it with an initial query string.

# Make the script executable
chmod +x rfv

# Run it with the initial query "algo"
./rfv algo

Ripgrep will perform the initial search and list all the lines that contain algo. Then we further narrow down the list on fzf.

I know it’s a lot to digest, let’s try to break down the code.

Using fzf as interative Ripgrep launcher

We have learned that we can bind reload action to a key (e.g. --bind=ctrl-r:execute(ps -ef)). In the next example, we are going to bind reload action to change event so that whenever the user changes the query string on fzf, reload action is triggered.

Here is a variation of the above rfv script. fzf will restart Ripgrep every time the user updates the query string on fzf. Searching and filtering is completely done by Ripgrep, and fzf merely provides the interactive interface. So we lose the “fuzziness”, but the performance will be better on larger projects, and it will free up memory as you narrow down the results.

#!/usr/bin/env bash

# 1. Search for text in files using Ripgrep
# 2. Interactively restart Ripgrep with reload action
# 3. Open the file in Vim
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}"
IFS=: read -ra selected < <(
  FZF_DEFAULT_COMMAND="$RG_PREFIX $(printf %q "$INITIAL_QUERY")" \
  fzf --ansi \
      --disabled --query "$INITIAL_QUERY" \
      --bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
      --delimiter : \
      --preview 'bat --color=always {1} --highlight-line {2}' \
      --preview-window 'up,60%,border-bottom,+{2}+3/3,~3'
)
[ -n "${selected[0]}" ] && vim "${selected[0]}" "+${selected[1]}"

Switching to fzf-only search mode

(Requires fzf 0.27.1 or above)

In the previous example, we lost fuzzy matching capability as we completely delegated search functionality to Ripgrep. But we can dynamically switch to fzf-only search mode by “unbinding” reload action from change event.

#!/usr/bin/env bash

# Two-phase filtering with Ripgrep and fzf
#
# 1. Search for text in files using Ripgrep
# 2. Interactively restart Ripgrep with reload action
#    * Press alt-enter to switch to fzf-only filtering
# 3. Open the file in Vim
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}"
IFS=: read -ra selected < <(
  FZF_DEFAULT_COMMAND="$RG_PREFIX $(printf %q "$INITIAL_QUERY")" \
  fzf --ansi \
      --color "hl:-1:underline,hl+:-1:underline:reverse" \
      --disabled --query "$INITIAL_QUERY" \
      --bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
      --bind "alt-enter:unbind(change,alt-enter)+change-prompt(2. fzf> )+enable-search+clear-query" \
      --prompt '1. ripgrep> ' \
      --delimiter : \
      --preview 'bat --color=always {1} --highlight-line {2}' \
      --preview-window 'up,60%,border-bottom,+{2}+3/3,~3'
)
[ -n "${selected[0]}" ] && vim "${selected[0]}" "+${selected[1]}"

Log tailing

fzf can run long-running preview commands and render partial results before completion. And when you specify follow flag in --preview-window option, fzf will “tail -f” the result, automatically scrolling to the bottom.

# With "follow", preview window will automatically scroll to the bottom.
# "\033[2J" is an ANSI escape sequence for clearing the screen.
# When fzf reads this code it clears the previous preview contents.
fzf --preview-window follow --preview 'for i in $(seq 100000); do
  echo "$i"
  sleep 0.01
  (( i % 300 == 0 )) && printf "\033[2J"
done'

Admittedly, that was a silly example. Here’s a practical one for browsing Kubernetes pods.

#!/usr/bin/env bash

read -ra tokens < <(
  kubectl get pods --all-namespaces |
    fzf --info=inline --layout=reverse --header-lines=1 --border \
        --prompt "$(kubectl config current-context | sed 's/-context$//')> " \
        --header $'Press CTRL-O to open log in editor\n\n' \
        --bind ctrl-/:toggle-preview \
        --bind 'ctrl-o:execute:${EDITOR:-vim} <(kubectl logs --namespace {1} {2}) > /dev/tty' \
        --preview-window up,follow \
        --preview 'kubectl logs --follow --tail=100000 --namespace {1} {2}' "$@"
)
[ ${#tokens} -gt 1 ] &&
  kubectl exec -it --namespace "${tokens[0]}" "${tokens[1]}" -- bash

Key bindings for git objects

I have blogged about my fzf+git key bindings a few years ago. I’m going to show them here again, because they are seriously useful.

Files listed in git status

<kbd>CTRL-G</kbd><kbd>CTRL-F</kbd>

Branches

<kbd>CTRL-G</kbd><kbd>CTRL-B</kbd>

Commit hashes

<kbd>CTRL-G</kbd><kbd>CTRL-H</kbd>

The full source code can be found here.

Color themes

You can customize how fzf colors the text elements with --color option. Here are a few color themes. Note that you need a terminal emulator that can display 24-bit colors.

# junegunn/seoul256.vim (dark)
export FZF_DEFAULT_OPTS='--color=bg+:#3F3F3F,bg:#4B4B4B,border:#6B6B6B,spinner:#98BC99,hl:#719872,fg:#D9D9D9,header:#719872,info:#BDBB72,pointer:#E12672,marker:#E17899,fg+:#D9D9D9,preview-bg:#3F3F3F,prompt:#98BEDE,hl+:#98BC99'

# junegunn/seoul256.vim (light)
export FZF_DEFAULT_OPTS='--color=bg+:#D9D9D9,bg:#E1E1E1,border:#C8C8C8,spinner:#719899,hl:#719872,fg:#616161,header:#719872,info:#727100,pointer:#E12672,marker:#E17899,fg+:#616161,preview-bg:#D9D9D9,prompt:#0099BD,hl+:#719899'

# morhetz/gruvbox
export FZF_DEFAULT_OPTS='--color=bg+:#3c3836,bg:#32302f,spinner:#fb4934,hl:#928374,fg:#ebdbb2,header:#928374,info:#8ec07c,pointer:#fb4934,marker:#fb4934,fg+:#ebdbb2,prompt:#fb4934,hl+:#fb4934'

# arcticicestudio/nord-vim
export FZF_DEFAULT_OPTS='--color=bg+:#3B4252,bg:#2E3440,spinner:#81A1C1,hl:#616E88,fg:#D8DEE9,header:#616E88,info:#81A1C1,pointer:#81A1C1,marker:#81A1C1,fg+:#D8DEE9,prompt:#81A1C1,hl+:#81A1C1'

# tomasr/molokai
export FZF_DEFAULT_OPTS='--color=bg+:#293739,bg:#1B1D1E,border:#808080,spinner:#E6DB74,hl:#7E8E91,fg:#F8F8F2,header:#7E8E91,info:#A6E22E,pointer:#A6E22E,marker:#F92672,fg+:#F8F8F2,prompt:#F92672,hl+:#F92672'

Generating fzf color theme from Vim color schemes

The Vim plugin of fzf can generate --color option from the current color scheme according to g:fzf_colors variable. You can find the detailed explanation here.

Here is an example. Add this to your Vim configuration file.

let g:fzf_colors =
\ { 'fg':         ['fg', 'Normal'],
  \ 'bg':         ['bg', 'Normal'],
  \ 'preview-bg': ['bg', 'NormalFloat'],
  \ 'hl':         ['fg', 'Comment'],
  \ 'fg+':        ['fg', 'CursorLine', 'CursorColumn', 'Normal'],
  \ 'bg+':        ['bg', 'CursorLine', 'CursorColumn'],
  \ 'hl+':        ['fg', 'Statement'],
  \ 'info':       ['fg', 'PreProc'],
  \ 'border':     ['fg', 'Ignore'],
  \ 'prompt':     ['fg', 'Conditional'],
  \ 'pointer':    ['fg', 'Exception'],
  \ 'marker':     ['fg', 'Keyword'],
  \ 'spinner':    ['fg', 'Label'],
  \ 'header':     ['fg', 'Comment'] }

Then you can see how the --color option is generated by printing the result of fzf#wrap().

:echo fzf#wrap()

Use this command to append export FZF_DEFAULT_OPTS="..." line to the end of the current file.

:call append('$', printf('export FZF_DEFAULT_OPTS="%s"', matchstr(fzf#wrap().options, "--color[^']*")))