18 out 2026 · linux, terminal, ferramentas · 6 min
O que muda no terminal quando o fzf assume a busca
Em 50 mil caminhos, a busca por migrcaes_hndler devolveu zero linha no grep e as 500 no fzf. Mesma lista, mesma máquina, dois caracteres fora do lugar: o grep exige o texto exato, o fzf casa as letras na ordem em que aparecem e ignora o que sobrou. É esse o motivo de o fzf existir, e é o que muda no seu dia a dia em três lugares.
A troca é boa e tem preço: filtrar 50 mil linhas levou 84 ms no fzf contra 2 ms do grep. Numa lista de histórico, ninguém sente essa diferença.
O script
O fzf-terminal.sh abaixo instala o fzf pelo gerenciador de pacotes, escreve as três integrações em um arquivo para bash e outro para zsh, e confere a sintaxe dos dois com bash -n e zsh -n. Sem fzf, sem git ou sem rede ele não trava: escreve o que dá, avisa o que faltou e sai com código 0.
#!/usr/bin/env bash
# fzf-terminal.sh - instala o fzf e liga as tres integracoes que mudam o dia a dia:
# historico, arquivos e troca de branch.
#
# Uso:
# ./fzf-terminal.sh instala o fzf e escreve a configuracao
# ./fzf-terminal.sh --sem-instalar so escreve a configuracao e roda a prova
# ./fzf-terminal.sh --provar so roda as tres buscas e mostra o resultado
#
# Sem fzf, sem git ou sem rede o script nao trava: ele escreve o que da para
# escrever, confere a sintaxe e diz o que faltou. Sai com codigo 0 nos caminhos.
set -u
MODO="${1:-instalar}"
CFG_BASH="$HOME/.fzf-terminal.bash"
CFG_ZSH="$HOME/.fzf-terminal.zsh"
DEMO="$HOME/.fzf-demo"
log() { printf '%s\n' "$*"; }
tem_cmd() { command -v "$1" >/dev/null 2>&1; }
instalar_pacotes() {
local g="$1" pacotes="$2" sudo_cmd=""
if [ "$(id -u)" -ne 0 ]; then
if ! tem_cmd sudo; then return 1; fi
sudo_cmd="sudo"
fi
case "$g" in
apt-get)
$sudo_cmd apt-get update -qq >/dev/null 2>&1
# shellcheck disable=SC2086
$sudo_cmd apt-get install -y --no-install-recommends $pacotes >/dev/null 2>&1 ;;
dnf) # shellcheck disable=SC2086
$sudo_cmd dnf install -y $pacotes >/dev/null 2>&1 ;;
pacman) # shellcheck disable=SC2086
$sudo_cmd pacman -S --noconfirm --needed $pacotes >/dev/null 2>&1 ;;
apk) # shellcheck disable=SC2086
$sudo_cmd apk add --no-cache $pacotes >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
gerenciador() {
local g
for g in apt-get dnf pacman apk; do
if tem_cmd "$g"; then printf '%s' "$g"; return 0; fi
done
return 1
}
instalar() {
if tem_cmd fzf; then
log "fzf: ja instalado ($(fzf --version))"
return 0
fi
if ! g=$(gerenciador); then
log "fzf: sem gerenciador de pacotes conhecido, pulei a instalacao"
return 1
fi
log "fzf ausente: instalando com $g"
# git entra junto porque a integracao de branch depende dele.
if instalar_pacotes "$g" "fzf git"; then
log "fzf e git instalados"
fi
if tem_cmd fzf; then
log "fzf: $(fzf --version)"
return 0
fi
log "fzf: nao instalado (sem root ou sem rede); o resto do script continua"
return 1
}
# As tres integracoes. Tudo em funcao, para o mesmo arquivo servir em bash e zsh.
# --filter e o modo nao interativo do fzf: sem ele, sem terminal, nada aparece.
escrever_cfg() {
{
printf '# ~/.fzf-terminal.bash e ~/.fzf-terminal.zsh, gerados por fzf-terminal.sh\n'
printf '# Va para o rc com: source ~/.fzf-terminal.$(basename "$SHELL")\n\n'
printf '# 1. historico: busca por subtexto e devolve o comando na linha\n'
printf 'fhist() {\n'
printf ' local escolha\n'
printf ' escolha=$(fc -lnr -1000 2>/dev/null | fzf --scheme=history --reverse --prompt="hist> ")\n'
printf ' [ -z "$escolha" ] && return 0\n'
printf ' # print -z so existe no zsh; no bash o comando volta pela READLINE_LINE\n'
printf ' if [ -n "${ZSH_VERSION:-}" ]; then\n'
printf ' print -z -- "$escolha"\n'
printf ' fi\n'
printf ' READLINE_LINE="$escolha"; READLINE_POINT=${#READLINE_LINE}\n'
printf '}\n\n'
printf '# 2. arquivos: fuzzy sobre find e abre no editor\n'
printf 'ff() {\n'
printf ' local arquivo\n'
printf ' arquivo=$(find "${1:-.}" -type f -not -path "*/.git/*" 2>/dev/null | fzf --reverse --prompt="arquivo> ")\n'
printf ' [ -z "$arquivo" ] && return 0\n'
printf ' "${EDITOR:-vi}" "$arquivo"\n'
printf '}\n\n'
printf '# 3. branch: troca de branch a partir da lista\n'
printf 'fb() {\n'
printf ' local branch\n'
printf ' command -v git >/dev/null 2>&1 || { echo "git nao instalado"; return 1; }\n'
printf ' branch=$(git branch --format="%%(refname:short)" 2>/dev/null | fzf --reverse --prompt="branch> ")\n'
printf ' [ -z "$branch" ] && return 0\n'
printf ' git switch "$branch"\n'
printf '}\n\n'
printf '# Ctrl-R no historico\n'
printf 'if [ -n "${ZSH_VERSION:-}" ]; then\n'
printf ' zle -N fhist\n'
printf ' bindkey "^R" fhist\n'
printf 'else\n'
printf ' bind -x "\\C-r": fhist 2>/dev/null\n'
printf 'fi\n'
} > "$CFG_BASH.tmp"
{
cat "$CFG_BASH.tmp"
printf '\n# Ctrl-T abre o seletor de arquivos, que so o zsh faz com fzf\n'
printf 'fzf-arquivo-widget() { ff; zle reset-prompt }\n'
printf 'zle -N fzf-arquivo-widget\n'
printf 'bindkey "^T" fzf-arquivo-widget\n'
} > "$CFG_ZSH.tmp"
mv "$CFG_BASH.tmp" "$CFG_BASH"
mv "$CFG_ZSH.tmp" "$CFG_ZSH"
chmod 600 "$CFG_BASH" "$CFG_ZSH"
log "configuracao escrita: $CFG_BASH e $CFG_ZSH"
}
# Confere a sintaxe de verdade: bash -n e zsh -n leem o arquivo sem executar nada.
conferir_sintaxe() {
local problema=0
if tem_cmd bash; then
if bash -n "$CFG_BASH" 2>/dev/null; then
log "sintaxe do arquivo bash: ok"
else
log "sintaxe do arquivo bash: ERRO"
problema=1
fi
fi
if tem_cmd zsh; then
if zsh -n "$CFG_ZSH" 2>/dev/null; then
log "sintaxe do arquivo zsh: ok"
else
log "sintaxe do arquivo zsh: ERRO"
problema=1
fi
fi
return "$problema"
}
# Cenario de mentira: arquivos, historico e um repositorio com branches.
# Nada disso depende do que existe na maquina de quem roda.
montar_cenario() {
local i
mkdir -p "$DEMO"
i=0
while [ "$i" -lt 60 ]; do
mkdir -p "$DEMO/modulo$((i % 6))"
printf 'linha %d do arquivo de teste\n' "$i" > "$DEMO/modulo$((i % 6))/arquivo$i.txt"
i=$((i + 1))
done
printf '%s\n' \
'git status' \
'systemctl restart nginx' \
'tar xzf backup.tar.gz' \
'docker ps -a' \
'grep -rn timeout /etc' \
'python manage.py migrate' \
'systemctl status postgresql' \
'tar czf arquivos.tar.gz notas' \
'docker compose up -d' > "$DEMO/historico.txt"
if ! tem_cmd git; then
log "cenario: git ausente, o repositorio de teste nao foi criado"
return 1
fi
mkdir -p "$DEMO/repo"
(
cd "$DEMO/repo" || exit 1
git init -q 2>/dev/null
git config user.email [email protected]
git config user.name teste
printf 'conteudo\n' > leia-me.txt
git add leia-me.txt
git commit -q -m "primeiro commit" 2>/dev/null
for b in correcao-taxa nova-tela-manutencao correcao-critica; do
git branch "$b" 2>/dev/null
done
)
log "cenario montado: 60 arquivos, 9 comandos no historico, 4 branches"
return 0
}
# As tres buscas no modo --filter, que e o mesmo motor do modo interativo sem
# a parte de desenhar a tela. E o que permite a prova rodar sem terminal.
provar() {
if ! tem_cmd fzf; then
log "prova: fzf ausente, nao ha o que filtrar"
return 1
fi
log "fzf: $(fzf --version)"
montar_cenario || true
log ""
log "== 1. historico: busca por 'systemclt', com o 's' fora do lugar =="
log "grep achou $(grep -c 'systemclt' "$DEMO/historico.txt" 2>/dev/null) linha(s)"
log "fzf achou:"
fzf --scheme=history --filter='systemclt' < "$DEMO/historico.txt" | sed 's/^/ /'
log ""
log "== 2. arquivos: busca por 'aqui54', sem ponto e sem o v =="
log "fzf achou:"
find "$DEMO" -type f 2>/dev/null | fzf --filter='aqui54' | sed "s|$DEMO/||; s/^/ /"
log ""
if tem_cmd git && [ -d "$DEMO/repo/.git" ]; then
log "== 3. branches: busca por 'corr' =="
local achada
achada=$( (cd "$DEMO/repo" && git branch --format='%(refname:short)' 2>/dev/null) | fzf --filter='corr' | head -n1 )
log "fzf achou:"
(cd "$DEMO/repo" && git branch --format='%(refname:short)' 2>/dev/null) | fzf --filter='corr' | sed 's/^/ /'
log "primeira da lista, que e a que o fzf entregaria: ${achada:-nenhuma}"
else
log "== 3. branches: sem git, pulei a prova =="
fi
return 0
}
case "$MODO" in
--provar)
provar || true
log "fim"
exit 0
;;
--sem-instalar)
log "modo --sem-instalar: nada foi instalado"
if tem_cmd fzf; then log "fzf presente: $(fzf --version)"; else log "fzf ausente"; fi
if tem_cmd git; then log "git presente: $(git --version | cut -d' ' -f3)"; else log "git ausente"; fi
;;
*)
instalar || true
;;
esac
escrever_cfg
conferir_sintaxe || log "a configuracao precisa de revisao antes de ser usada"
provar || true
log "fim: acrescente source ~/.fzf-terminal.bash no seu rc e abra um terminal novo"
exit 0
Depois de rodar, acrescente source ~/.fzf-terminal.bash no seu ~/.bashrc (ou a versão .zsh no ~/.zshrc) e abra um terminal novo. O Ctrl-R passa a chamar o fhist, e as funções ff e fb ficam disponíveis.
Por que o grep não serve para isso
O grep procura substring. Se você não souber de cor quantos caracteres o caminho tem, se o separador é - ou _, se o número tem dois dígitos, a busca volta vazia e você não descobre se errou o termo ou se o arquivo não existe. O fzf casa as letras na ordem em que aparecem, sem exigir que fiquem coladas.
Com o texto certo, os dois acham as mesmas 500 linhas. Com dois caracteres trocados, o grep acha 0 e o fzf acha as 500. A lista era a mesma nos quatro casos, com 50 mil caminhos e 500 deles contendo migracoes_handler.
O preço está no tempo. Filtrar as 50 mil linhas levou 2 ms no grep -c e 84 ms no fzf --filter, mediana de cinco execuções com uma de aquecimento. Para histórico de shell, esse segundo número não aparece na sua percepção. Para varrer um terabyte de log linha a linha, aparece.
O fzf casa letras, não sentido. Se a sua busca é por significado e não por nome, o caminho é outro, como mostra a busca por similaridade com sqlite-vec.
Como verificar
Rode ./fzf-terminal.sh. A saída de uma execução num contêiner ubuntu 24.04 foi esta:
fzf ausente: instalando com apt-get
fzf e git instalados
fzf: 0.44.1 (debian)
configuracao escrita: /root/.fzf-terminal.bash e /root/.fzf-terminal.zsh
sintaxe do arquivo bash: ok
fzf: 0.44.1 (debian)
cenario montado: 60 arquivos, 9 comandos no historico, 4 branches
== 1. historico: busca por 'systemclt', com o 's' fora do lugar ==
grep achou 0 linha(s)
fzf achou:
systemctl status postgresql
systemctl restart nginx
== 2. arquivos: busca por 'aqui54', sem ponto e sem o v ==
fzf achou:
modulo0/arquivo54.txt
== 3. branches: busca por 'corr' ==
fzf achou:
correcao-taxa
correcao-critica
primeira da lista, que e a que o fzf entregaria: correcao-taxa
fim: acrescente source ~/.fzf-terminal.bash no seu rc e abra um terminal novo
grep achou 0 linha(s) seguido das duas linhas do systemctl é a prova de que a diferença é real e não impressão minha.
Se der errado
Failed to open /dev/tty ao chamar a função. Acontece quando você chama ff ou fb de um script, de um cron ou de um container, onde não existe terminal para o fzf desenhar. Nessas situações use fzf --filter, que imprime o resultado e sai. A função é feita para o prompt.
O Ctrl-R não faz nada depois do source. No bash o bind -x só vale em shell interativo; se você sorgou o arquivo de um script, nada é ligado. Rode o source no terminal e abra outra aba para conferir.
A busca por branch devolve nada. A função usa git branch --format, que exige git 2.13 ou mais novo. Em git mais antigo, troque por git branch | cut -c3-.
fzf contra as alternativas
| opção | o que faz | tempo em 50 mil linhas | custo |
|---|---|---|---|
| grep | substring exata | 2 ms | já vem na distro |
| fzf –filter | fuzzy, não interativo | 84 ms | binário de 5 MB |
| skim | fuzzy, mais rápido, em Rust | não medido aqui | precisa de nightly |
| bash completion | completa o que já existe | não se aplica | já vem na distro |
O skim promete ser mais rápido, mas exige a toolchain do Rust para compilar. O fzf é um binário só, e o git dele continua ativo: a versão 0.74.4 saiu em 12 de setembro de 2026.
FAQ
O fzf funciona sem shell interativo? A busca sim, com --filter. É assim que a prova deste post roda num contêiner sem terminal. O que exige terminal é a interface, com a lista desenhada embaixo e o Cursor.
Preciso de fzf para achar arquivo pelo nome? Não. find | grep resolve, e o ff é só para não decorar o caminho. Onde o fzf ganha mesmo é no histórico, onde você não lembra o comando exato.
Como faço o histórico contar mais comandos? O fc -lnr -1000 na função fhist lê as últimas mil linhas. Troque por fc -lnr -5000 se o seu histórico for maior, e ajuste o HISTSIZE do shell para caber.
Funciona no fish? Não direto. O fzf tem integração própria para fish, com fzf --fish | source, e a função de histórico precisa ser reescrita, porque o fc do fish é outro. O resto do raciocínio é igual.
Fontes
- junegunn/fzf, repositório e documentação, licença MIT, leitura em 18 de outubro de 2026.
- fzf(1), página de manual do Ubuntu, com as opções
--scheme,--filtere--tiebreak. - fzf 0.74.4, versão publicada em 12 de setembro de 2026.
Medições em contêiner descartável ubuntu 24.04, com 0,5 CPU e 256 MB de RAM, fzf 0.44.1 do pacote da distro: lista de 50 mil caminhos gerada com awk, mediana de cinco execuções depois de uma de aquecimento. Detalhes de método e as contagens completas estão no post sobre containers sem root, que mediu do mesmo jeito.