Compare commits

..

16 Commits

Author SHA1 Message Date
Junegunn Choi f5fbfd848e Let bw theme inherit overridden colors
- Mark derived color slots undefined so fg/bg/list-bg propagate
- Add missing Footer slot (was rendering black)
2026-06-07 18:12:19 +09:00
Junegunn Choi dea72834ed Keep base fg/bg when resolving colors in bw theme
Trailing prompt space and other base-colored segments no longer reset to terminal default
2026-06-07 18:12:19 +09:00
LangLangBart abee152255 test: ALT-C regression tests
discussed in https://github.com/junegunn/fzf/issues/4816
2026-06-05 16:24:27 +09:00
LangLangBart bf114bcc21 test: install nushell in Dockerfile
ref: https://docs.docker.com/reference/dockerfile/#here-documents
2026-06-05 16:24:27 +09:00
Junegunn Choi 838ac7554b make lint 2026-06-02 20:23:23 +09:00
Junegunn Choi ae78a5c56d Allow bare put action in transform output
transform/bg-transform now permit bare `put`, inserting the key that
triggered the action (`a:transform:echo put` puts `a`).
2026-06-02 20:21:04 +09:00
Yi-Yo Chiang 7d647c70c2 [shell][zsh] Don't resolve symlinks in ALT-c (#4816)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.
2026-05-31 12:38:12 +09:00
Junegunn Choi 6bd17f8f9a Revert "[shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)"
This reverts commit 249a6df4a4.
2026-05-31 12:37:05 +09:00
Junegunn Choi 249a6df4a4 [shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.

Co-authored-by: Yi-Yo Chiang <5255547+silverneko@users.noreply.github.com>

Close #4816
2026-05-31 12:34:58 +09:00
Junegunn Choi a50619388d [install] Fix empty-shell detection in install script
${#shells} is the string length, not the shell count.

Thanks to @matheus-pacifico for the report.

Close #4813
2026-05-28 23:02:34 +09:00
Copilot 5ef8dea36e Prevent nushell source contamination in install shell loop (#4812) 2026-05-28 10:26:43 +09:00
Junegunn Choi 845752f305 Update README 2026-05-25 22:28:11 +09:00
Junegunn Choi 9a61a1457d Bump action versions for Node.js 24 support 2026-05-25 21:11:25 +09:00
Junegunn Choi dfcacb443d Allow manual dispatch of Winget workflow 2026-05-25 21:09:25 +09:00
Junegunn Choi 5412f39b84 Use PAT in release workflow
Releases created with the default GITHUB_TOKEN do not trigger other
workflows (anti-recursion). Winget workflow therefore did not fire
on v0.73.1. Switch to RELEASE_PAT (registered in the `release`
environment) so the release is authored by the user.
2026-05-25 21:08:48 +09:00
Junegunn Choi 07c5cd4185 Fix typo in CHANGELOG 2026-05-25 14:39:05 +09:00
16 changed files with 166 additions and 69 deletions
+4 -4
View File
@@ -19,11 +19,11 @@ jobs:
runs-on: macos-latest
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: stable
@@ -60,7 +60,7 @@ jobs:
| tee tmp/release-note
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: >-
@@ -68,7 +68,7 @@ jobs:
&& 'release --clean --release-notes tmp/release-note'
|| 'release --snapshot --clean --skip=publish' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
+7
View File
@@ -2,6 +2,12 @@ name: Publish to Winget
on:
release:
types: [released]
workflow_dispatch:
inputs:
release-tag:
description: 'Release tag to submit (e.g. v0.73.1)'
required: true
type: string
jobs:
publish:
@@ -10,5 +16,6 @@ jobs:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
token: ${{ secrets.WINGET_TOKEN }}
+1 -1
View File
@@ -4,7 +4,7 @@ CHANGELOG
0.73.1
------
- Bug fixes
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#2395)
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
0.73.0
+11 -1
View File
@@ -1,5 +1,15 @@
FROM rubylang/ruby:3.4.1-noble
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile
+9 -9
View File
@@ -25,22 +25,22 @@
---
fzf is a general-purpose command-line fuzzy finder.
fzf is a general-purpose command-line fuzzy finder and an interactive terminal toolkit.
<img src="https://raw.githubusercontent.com/junegunn/i/master/fzf-preview.png" width=640>
It's an interactive filter program for any kind of list; files, command
history, processes, hostnames, bookmarks, git commits, etc. It implements
a "fuzzy" matching algorithm, so you can quickly type in patterns with omitted
characters and still get the results you want.
Whether you're selecting files, browsing command history, previewing data,
navigating complex datasets with fuzzy matching, or creating custom menus and
workflows, fzf provides the building blocks to turn shell scripts into rich
terminal applications.
Highlights
----------
- **Portable** -- Distributed as a single binary for easy installation
- **Fast** -- Optimized to process millions of items instantly
- **Versatile** -- Fully customizable through an event-action binding mechanism
- **All-inclusive** -- Comes with integrations for Bash, Zsh, Fish, Nushell, Vim, and Neovim
- **Portable** // Distributed as a single binary for easy installation
- **Fast** // Optimized to process millions of items in milliseconds
- **Programmable** // Event-driven architecture for building custom terminal interfaces and workflows
- **Batteries-included** // Comes with integrations for Bash, Zsh, Fish, Nushell, Vim, and Neovim
Table of Contents
-----------------
+4 -4
View File
@@ -227,13 +227,13 @@ fi
for s in $shells; do
bin=$s
[[ "$s" = nushell ]] && bin=nu
[[ $s == nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/}
fi
done
if [[ ${#shells} -lt 3 ]]; then
if [[ -z ${shells// /} ]]; then
echo "No shell configuration to be updated."
exit 0
fi
@@ -252,10 +252,10 @@ fi
echo
for shell in $shells; do
[[ $shell == nushell ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue
[[ $shell == nushell ]] && continue
src=${prefix_expand}.${shell}
echo -n "Generate $src ... "
@@ -442,7 +442,7 @@ if [[ $shells =~ fish ]]; then
fi
fi
if [[ "$shells" =~ nushell ]]; then
if [[ $shells =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
+7 -1
View File
@@ -110,8 +110,14 @@ fzf-cd-widget() {
zle redisplay
return 0
fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir:a}"
BUFFER="builtin cd -- ${(q)dir}"
zle accept-line
local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion
+3 -5
View File
@@ -10,7 +10,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
@@ -1734,10 +1733,10 @@ Loop:
return masked
}
func parseSingleActionList(str string) ([]*action, error) {
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false)
return parseActionList(masked, str, []*action{}, putAllowed)
}
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -2043,8 +2042,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
}
key = firstKey(keys)
}
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
if err != nil {
return err
}
+2 -2
View File
@@ -572,7 +572,7 @@ func TestValidateSign(t *testing.T) {
}
func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions))
}
@@ -588,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
}
func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz")
_, err := parseSingleActionList("change-query(foobar)baz", false)
if err == nil {
t.Errorf("Failed to detect error")
}
+4 -1
View File
@@ -198,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored {
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
// Ignore ANSI colors but keep the attributes. Retain the base
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
}
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() {
+1 -1
View File
@@ -240,7 +240,7 @@ Loop:
}
body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
if err != nil {
return bad(err.Error())
}
+2 -1
View File
@@ -6973,7 +6973,8 @@ func (t *Terminal) Loop() error {
})
case actTransform, actBgTransform:
capture(false, func(body string) {
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil {
// Allow 'put' if the triggering key is a printable character
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n"), event.Printable()); err == nil {
// NOTE: We're not properly passing the return value here
doActions(actions)
}
+50 -38
View File
@@ -4,6 +4,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
@@ -252,6 +253,12 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil}
}
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil {
return me.Name()
@@ -995,51 +1002,56 @@ func init() {
undefined := ColorAttr{colUndefined, AttrUndefined}
NoColorTheme = &ColorTheme{
Colored: false,
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
ListFg: defaultColor,
ListBg: defaultColor,
Colored: false,
// Root colors. Everything else is left undefined so that overriding a
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
// just like in the colored base themes.
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Footer: defaultColor,
BorderLabel: defaultColor,
// Derived colors. Left undefined so they inherit from a root.
ListFg: undefined,
ListBg: undefined,
AltBg: undefined,
SelectedFg: defaultColor,
SelectedBg: defaultColor,
SelectedMatch: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
SelectedFg: undefined,
SelectedBg: undefined,
SelectedMatch: undefined,
Current: undefined,
CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined,
Disabled: defaultColor,
PreviewFg: defaultColor,
PreviewBg: defaultColor,
Disabled: undefined,
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor,
ListLabel: defaultColor,
ListBorder: defaultColor,
Separator: defaultColor,
Scrollbar: defaultColor,
InputBg: defaultColor,
InputBorder: defaultColor,
InputLabel: defaultColor,
HeaderBg: defaultColor,
HeaderBorder: defaultColor,
HeaderLabel: defaultColor,
FooterBg: defaultColor,
FooterBorder: defaultColor,
FooterLabel: defaultColor,
GapLine: defaultColor,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
ListLabel: undefined,
ListBorder: undefined,
Separator: undefined,
Scrollbar: undefined,
InputBg: undefined,
InputBorder: undefined,
InputLabel: undefined,
HeaderBg: undefined,
HeaderBorder: undefined,
HeaderLabel: undefined,
FooterBg: undefined,
FooterBorder: undefined,
FooterLabel: undefined,
GapLine: undefined,
Nth: undefined,
Nomatch: undefined,
}
+2 -1
View File
@@ -136,6 +136,7 @@ class Tmux
rescue Minitest::Assertion
retries += 1
raise if retries > 5
retry
end
send_keys 'clear', :Enter
@@ -295,7 +296,7 @@ class Tmux
if @shell == :nushell
message = "Prepare[#{tries}]"
send_keys 'C-u', 'C-l'
sleep 0.2
sleep(0.2)
send_keys ' ', 'C-u', :Enter, message
self.until { |lines| lines[-1] == message }
else
+18
View File
@@ -971,6 +971,24 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
end
def test_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal '> a', lines.last }
tmux.send_keys :b
tmux.until { |lines| assert_equal '> ab', lines.last }
end
# The async callback runs in a later iteration, but 'put' must still insert
# the key that triggered the bg-transform (snapshot of the scheduling event).
def test_bg_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys 'ab'
tmux.until { |lines| assert_equal '> ba', lines.last }
end
def test_accept_non_empty
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
+41
View File
@@ -100,6 +100,47 @@ module TestShell
tmux.until { |lines| assert_equal '/tmp', lines[-1] }
end
def test_alt_c_symlink
base = '/tmp/fzf-test-alt-c-symlink'
FileUtils.rm_rf(base)
FileUtils.mkdir_p("#{base}/real/subdir")
FileUtils.ln_s("#{base}/real", "#{base}/link")
tmux.prepare
tmux.send_keys "cd #{base}/link", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'subdir'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal "#{base}/link/subdir", lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_alt_c_absolute_cmd
base = '/tmp/fzf-test-alt-c-absolute'
FileUtils.rm_rf(base)
FileUtils.mkdir_p(base)
set_var('FZF_ALT_C_COMMAND', "echo #{base}")
tmux.prepare
tmux.send_keys 'cd /tmp', :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal base, lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_ctrl_r
tmux.prepare
tmux.send_keys 'echo 1st', :Enter