m/fzf
1
0
mirror of https://github.com/junegunn/fzf.git synced 2025-11-17 15:53:39 -05:00

Add 'exclude' action for excluding current/selected items from the result (#4231)

Close #4185
This commit is contained in:
Junegunn Choi
2025-02-09 13:22:33 +09:00
committed by GitHub
parent 2b584586ed
commit 67dd7e1923
7 changed files with 118 additions and 11 deletions

View File

@@ -1601,6 +1601,7 @@ A key or an event can be bound to one or more of the following actions.
\fBdown\fR \fIctrl\-j ctrl\-n down\fR
\fBenable\-search\fR (enable search functionality)
\fBend\-of\-line\fR \fIctrl\-e end\fR
\fBexclude\fR (exclude the current item or the selected items from the result)
\fBexecute(...)\fR (see below for the details)
\fBexecute\-silent(...)\fR (see below for the details)
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR)

View File

@@ -198,10 +198,26 @@ func Run(opts *Options) (int, error) {
inputRevision := revision{}
snapshotRevision := revision{}
patternCache := make(map[string]*Pattern)
denyMutex := sync.Mutex{}
denylist := make(map[int32]struct{})
clearDenylist := func() {
denyMutex.Lock()
if len(denylist) > 0 {
patternCache = make(map[string]*Pattern)
}
denylist = make(map[int32]struct{})
denyMutex.Unlock()
}
patternBuilder := func(runes []rune) *Pattern {
denyMutex.Lock()
denylistCopy := make(map[int32]struct{})
for k, v := range denylist {
denylistCopy[k] = v
}
denyMutex.Unlock()
return BuildPattern(cache, patternCache,
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes)
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy)
}
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision)
@@ -301,6 +317,9 @@ func Run(opts *Options) (int, error) {
var snapshot []*Chunk
var count int
restart := func(command commandSpec, environ []string) {
if !useSnapshot {
clearDenylist()
}
reading = true
chunkList.Clear()
itemIndex = 0
@@ -347,7 +366,8 @@ func Run(opts *Options) (int, error) {
} else {
reading = reading && evt == EvtReadNew
}
if useSnapshot && evt == EvtReadFin {
if useSnapshot && evt == EvtReadFin { // reload-sync
clearDenylist()
useSnapshot = false
}
if !useSnapshot {
@@ -378,9 +398,21 @@ func Run(opts *Options) (int, error) {
command = val.command
environ = val.environ
changed = val.changed
bump := false
if len(val.denylist) > 0 && val.revision.compatible(inputRevision) {
denyMutex.Lock()
for _, itemIndex := range val.denylist {
denylist[itemIndex] = struct{}{}
}
denyMutex.Unlock()
bump = true
}
if val.nth != nil {
// Change nth and clear caches
nth = *val.nth
bump = true
}
if bump {
patternCache = make(map[string]*Pattern)
cache.Clear()
inputRevision.bumpMinor()

View File

@@ -1603,6 +1603,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
}
case "bell":
appendAction(actBell)
case "exclude":
appendAction(actExclude)
default:
t := isExecuteAction(specLower)
if t == actIgnore {

View File

@@ -63,6 +63,7 @@ type Pattern struct {
revision revision
procFun map[termType]algo.Algo
cache *ChunkCache
denylist map[int32]struct{}
}
var _splitRegex *regexp.Regexp
@@ -73,7 +74,7 @@ func init() {
// BuildPattern builds Pattern object from the given arguments
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune) *Pattern {
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}) *Pattern {
var asString string
if extended {
@@ -144,6 +145,7 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
revision: revision,
delimiter: delimiter,
cache: cache,
denylist: denylist,
procFun: make(map[termType]algo.Algo)}
ptr.cacheKey = ptr.buildCacheKey()
@@ -243,6 +245,9 @@ func parseTerms(fuzzy bool, caseMode Case, normalize bool, str string) []termSet
// IsEmpty returns true if the pattern is effectively empty
func (p *Pattern) IsEmpty() bool {
if len(p.denylist) > 0 {
return false
}
if !p.extended {
return len(p.text) == 0
}
@@ -296,14 +301,38 @@ func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
matches := []Result{}
if len(p.denylist) == 0 {
// Huge code duplication for minimizing unnecessary map lookups
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
}
}
return matches
}
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
}
} else {
for _, result := range space {
if _, prs := p.denylist[result.item.Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}

View File

@@ -68,7 +68,7 @@ func buildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
withPos, cacheable, nth, delimiter, revision{}, runes)
withPos, cacheable, nth, delimiter, revision{}, runes, nil)
}
func TestExact(t *testing.T) {

View File

@@ -584,6 +584,7 @@ const (
actShowHeader
actHideHeader
actBell
actExclude
)
func (a actionType) Name() string {
@@ -621,12 +622,14 @@ type placeholderFlags struct {
}
type searchRequest struct {
sort bool
sync bool
nth *[]Range
command *commandSpec
environ []string
changed bool
sort bool
sync bool
nth *[]Range
command *commandSpec
environ []string
changed bool
denylist []int32
revision revision
}
type previewRequest struct {
@@ -4751,6 +4754,7 @@ func (t *Terminal) Loop() error {
changed := false
beof := false
queryChanged := false
denylist := []int32{}
// Special handling of --sync. Activate the interface on the second tick.
if loopIndex == 1 && t.deferActivation() {
@@ -4907,6 +4911,21 @@ func (t *Terminal) Loop() error {
}
case actBell:
t.tui.Bell()
case actExclude:
if len(t.selected) > 0 {
for _, item := range t.sortSelected() {
denylist = append(denylist, item.item.Index())
}
// Clear selected items
t.selected = make(map[int32]selectedItem)
t.version++
} else {
item := t.currentItem()
if item != nil {
denylist = append(denylist, item.Index())
}
}
changed = true
case actExecute, actExecuteSilent:
t.executeCommand(a.a, false, a.t == actExecuteSilent, false, false, "")
case actExecuteMulti:
@@ -6016,7 +6035,7 @@ func (t *Terminal) Loop() error {
reload := changed || newCommand != nil
var reloadRequest *searchRequest
if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, command: newCommand, environ: t.environ(), changed: changed}
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.merger.Revision()}
}
t.mutex.Unlock() // Must be unlocked before touching reqBox

View File

@@ -1666,6 +1666,30 @@ class TestCore < TestInteractive
end
end
def test_exclude
tmux.send_keys %(seq 1000 | #{FZF} --multi --bind 'a:exclude,b:reload(seq 1000),c:reload-sync(seq 1000)'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.until { |lines| assert_includes lines, '> 1' }
tmux.send_keys :a
tmux.until { |lines| assert_includes lines, '> 2' }
tmux.until { |lines| assert_equal 999, lines.match_count }
tmux.send_keys :Up, :BTab, :BTab, :BTab, :a
tmux.until { |lines| assert_equal 996, lines.match_count }
tmux.until { |lines| assert_includes lines, '> 9' }
tmux.send_keys :b
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.until { |lines| assert_includes lines, '> 5' }
tmux.send_keys :Tab, :Tab, :Tab, :a
tmux.until { |lines| assert_equal 997, lines.match_count }
tmux.until { |lines| assert_includes lines, '> 2' }
tmux.send_keys :c
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.until { |lines| assert_includes lines, '> 2' }
# TODO: We should also check the behavior of 'exclude' during reloads
end
def test_accept_nth
tmux.send_keys %((echo "foo bar baz"; echo "bar baz foo") | #{FZF} --multi --accept-nth 2,2 --sync --bind start:select-all+accept > #{tempname}), :Enter
wait do