From 8bb0c5a5839f584471dde779b94baae1a58cd990 Mon Sep 17 00:00:00 2001 From: Abdullah Ahsan Date: Tue, 15 Nov 2022 20:41:14 +0100 Subject: [PATCH 1/4] Enable closing of fuzzyfinder from the caller by passing context --- fuzzyfinder.go | 99 ++++++++++++------- fuzzyfinder_test.go | 28 ++++++ helper_test.go | 10 ++ option.go | 17 +++- testdata/fixtures/testfind_withcontext.golden | 11 +++ 5 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 testdata/fixtures/testfind_withcontext.golden diff --git a/fuzzyfinder.go b/fuzzyfinder.go index 2cc965a..e96c9b1 100644 --- a/fuzzyfinder.go +++ b/fuzzyfinder.go @@ -25,6 +25,7 @@ import ( var ( // ErrAbort is returned from Find* functions if there are no selections. ErrAbort = errors.New("abort") + ErrCancel = errors.New("cancel") errEntered = errors.New("entered") ) @@ -76,6 +77,9 @@ type finder struct { drawTimer *time.Timer eventCh chan struct{} opt *opt + + termEventsChan <-chan tcell.Event + termQuitChan chan<- struct{} } func newFinder() *finder { @@ -94,6 +98,12 @@ func (f *finder) initFinder(items []string, matched []matching.Matched, opt opt) if err := f.term.Init(); err != nil { return errors.Wrap(err, "failed to initialize screen") } + + eventsChan := make(chan tcell.Event) + quitChan := make(chan struct{}) + go f.term.ChannelEvents(eventsChan, quitChan) + f.termEventsChan = eventsChan + f.termQuitChan = quitChan } f.opt = &opt @@ -442,9 +452,10 @@ func (f *finder) draw(d time.Duration) { } // readKey reads a key input. -// It returns ErrAbort if esc, CTRL-C or CTRL-D keys are inputted. -// Also, it returns errEntered if enter key is inputted. -func (f *finder) readKey() error { +// It returns ErrAbort if esc, CTRL-C or CTRL-D keys are inputted, +// errEntered in case of enter key, and ErrCancel when the passed +// context is cancelled. +func (f *finder) readKey(ctx context.Context) error { f.stateMu.RLock() prevInputLen := len(f.state.input) f.stateMu.RUnlock() @@ -457,7 +468,15 @@ func (f *finder) readKey() error { } }() - e := f.term.PollEvent() + var e tcell.Event + + select { + case ee := <-f.termEventsChan: + e = ee + case <-ctx.Done(): + return ErrCancel + } + f.stateMu.Lock() defer f.stateMu.Unlock() @@ -670,7 +689,14 @@ func (f *finder) find(slice interface{}, itemFunc func(i int) string, opts []Opt matched []matching.Matched ) - ctx, cancel := context.WithCancel(context.Background()) + var parentContext context.Context + if opt.context != nil { + parentContext = opt.context + } else { + parentContext = context.Background() + } + + ctx, cancel := context.WithCancel(parentContext) defer cancel() inited := make(chan struct{}) @@ -727,40 +753,45 @@ func (f *finder) find(slice interface{}, itemFunc func(i int) string, opts []Opt }() for { - f.draw(10 * time.Millisecond) - - err := f.readKey() - // hack for earning time to filter exec - if isInTesting() { - time.Sleep(50 * time.Millisecond) - } - switch { - case errors.Is(err, ErrAbort): - return nil, ErrAbort - case errors.Is(err, errEntered): - f.stateMu.RLock() - defer f.stateMu.RUnlock() + select { + case <-ctx.Done(): + return nil, ErrCancel + default: + f.draw(10 * time.Millisecond) - if len(f.state.matched) == 0 { - return nil, ErrAbort + err := f.readKey(ctx) + // hack for earning time to filter exec + if isInTesting() { + time.Sleep(50 * time.Millisecond) } - if f.opt.multi { - if len(f.state.selection) == 0 { - return []int{f.state.matched[f.state.y].Idx}, nil + switch { + case errors.Is(err, ErrAbort): + return nil, ErrAbort + case errors.Is(err, errEntered): + f.stateMu.RLock() + defer f.stateMu.RUnlock() + + if len(f.state.matched) == 0 { + return nil, ErrAbort } - poss, idxs := make([]int, 0, len(f.state.selection)), make([]int, 0, len(f.state.selection)) - for idx, pos := range f.state.selection { - idxs = append(idxs, idx) - poss = append(poss, pos) + if f.opt.multi { + if len(f.state.selection) == 0 { + return []int{f.state.matched[f.state.y].Idx}, nil + } + poss, idxs := make([]int, 0, len(f.state.selection)), make([]int, 0, len(f.state.selection)) + for idx, pos := range f.state.selection { + idxs = append(idxs, idx) + poss = append(poss, pos) + } + sort.Slice(idxs, func(i, j int) bool { + return poss[i] < poss[j] + }) + return idxs, nil } - sort.Slice(idxs, func(i, j int) bool { - return poss[i] < poss[j] - }) - return idxs, nil + return []int{f.state.matched[f.state.y].Idx}, nil + case err != nil: + return nil, errors.Wrap(err, "failed to read a key") } - return []int{f.state.matched[f.state.y].Idx}, nil - case err != nil: - return nil, errors.Wrap(err, "failed to read a key") } } } diff --git a/fuzzyfinder_test.go b/fuzzyfinder_test.go index 1b34fd6..9fcfa84 100644 --- a/fuzzyfinder_test.go +++ b/fuzzyfinder_test.go @@ -1,6 +1,7 @@ package fuzzyfinder_test import ( + "context" "flag" "io/ioutil" "log" @@ -445,6 +446,33 @@ func TestFind_WithPreviewWindow(t *testing.T) { } } +func TestFind_withContext(t *testing.T) { + t.Parallel() + + f, term := fuzzyfinder.NewWithMockedTerminal() + events := append(runes("adrena"), keys(input{tcell.KeyEsc, rune(tcell.KeyEsc), tcell.ModNone})...) + term.SetEventsV2(events...) + + cancelledCtx, cancelFunc := context.WithCancel(context.Background()) + cancelFunc() + + assertWithGolden(t, func(t *testing.T) string { + _, err := f.Find( + tracks, + func(i int) string { + return tracks[i].Name + }, + fuzzyfinder.WithContext(cancelledCtx), + ) + if !errors.Is(err, fuzzyfinder.ErrCancel) { + t.Fatalf("Find must return ErrAbort, but got '%s'", err) + } + + res := term.GetResult() + return res + }) +} + func TestFind_error(t *testing.T) { t.Parallel() diff --git a/helper_test.go b/helper_test.go index 9f65cb5..94b4191 100644 --- a/helper_test.go +++ b/helper_test.go @@ -1,12 +1,22 @@ package fuzzyfinder +import "github.com/gdamore/tcell/v2" + func New() *finder { return &finder{} } func NewWithMockedTerminal() (*finder, *TerminalMock) { + eventsChan := make(chan tcell.Event, 10) + quitChan := make(chan struct{}) + f := New() + f.termEventsChan = eventsChan + f.termQuitChan = quitChan + m := f.UseMockedTerminalV2() + go m.ChannelEvents(eventsChan, quitChan) + w, h := 60, 10 // A normally value. m.SetSize(w, h) return f, m diff --git a/option.go b/option.go index 1943e37..80ccf52 100644 --- a/option.go +++ b/option.go @@ -1,6 +1,9 @@ package fuzzyfinder -import "sync" +import ( + "context" + "sync" +) type opt struct { mode mode @@ -11,6 +14,7 @@ type opt struct { promptString string header string beginAtTop bool + context context.Context } type mode int @@ -27,7 +31,7 @@ const ( ) var defaultOption = opt{ - promptString: "> ", + promptString: "> ", hotReloadLock: &sync.Mutex{}, // this won't resolve the race condition but avoid nil panic } @@ -69,7 +73,7 @@ func WithHotReload() Option { // The caller must pass a pointer of the slice instead of the slice itself. // The caller must pass a RLock which is used to synchronize access to the slice. // The caller MUST NOT lock in the itemFunc passed to Find / FindMulti because it will be locked by the fuzzyfinder. -// If used together with WithPreviewWindow, the caller MUST use the RLock only in the previewFunc passed to WithPreviewWindow. +// If used together with WithPreviewWindow, the caller MUST use the RLock only in the previewFunc passed to WithPreviewWindow. func WithHotReloadLock(lock sync.Locker) Option { return func(o *opt) { o.hotReload = true @@ -116,3 +120,10 @@ func WithHeader(s string) Option { o.header = s } } + +// WithContext enables closing the fuzzy finder from parent. +func WithContext(ctx context.Context) Option { + return func(o *opt) { + o.context = ctx + } +} diff --git a/testdata/fixtures/testfind_withcontext.golden b/testdata/fixtures/testfind_withcontext.golden new file mode 100644 index 0000000..f10422b --- /dev/null +++ b/testdata/fixtures/testfind_withcontext.golden @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file From eb7f34d0721bfa1070fc51ad1ef7f30a03e782e4 Mon Sep 17 00:00:00 2001 From: Abdullah Ahsan Date: Fri, 18 Nov 2022 16:32:46 +0100 Subject: [PATCH 2/4] Remove unused quit channel --- fuzzyfinder.go | 5 +---- helper_test.go | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/fuzzyfinder.go b/fuzzyfinder.go index e96c9b1..12c7867 100644 --- a/fuzzyfinder.go +++ b/fuzzyfinder.go @@ -79,7 +79,6 @@ type finder struct { opt *opt termEventsChan <-chan tcell.Event - termQuitChan chan<- struct{} } func newFinder() *finder { @@ -100,10 +99,8 @@ func (f *finder) initFinder(items []string, matched []matching.Matched, opt opt) } eventsChan := make(chan tcell.Event) - quitChan := make(chan struct{}) - go f.term.ChannelEvents(eventsChan, quitChan) + go f.term.ChannelEvents(eventsChan, nil) f.termEventsChan = eventsChan - f.termQuitChan = quitChan } f.opt = &opt diff --git a/helper_test.go b/helper_test.go index 94b4191..13d3838 100644 --- a/helper_test.go +++ b/helper_test.go @@ -8,14 +8,12 @@ func New() *finder { func NewWithMockedTerminal() (*finder, *TerminalMock) { eventsChan := make(chan tcell.Event, 10) - quitChan := make(chan struct{}) f := New() f.termEventsChan = eventsChan - f.termQuitChan = quitChan m := f.UseMockedTerminalV2() - go m.ChannelEvents(eventsChan, quitChan) + go m.ChannelEvents(eventsChan, nil) w, h := 60, 10 // A normally value. m.SetSize(w, h) From 58dd031ae37b6720186fbd3a07686844345ee060 Mon Sep 17 00:00:00 2001 From: Abdullah Ahsan Date: Fri, 18 Nov 2022 16:36:55 +0100 Subject: [PATCH 3/4] Use ctx.Err --- fuzzyfinder.go | 5 ++--- fuzzyfinder_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/fuzzyfinder.go b/fuzzyfinder.go index 12c7867..3aab70c 100644 --- a/fuzzyfinder.go +++ b/fuzzyfinder.go @@ -25,7 +25,6 @@ import ( var ( // ErrAbort is returned from Find* functions if there are no selections. ErrAbort = errors.New("abort") - ErrCancel = errors.New("cancel") errEntered = errors.New("entered") ) @@ -471,7 +470,7 @@ func (f *finder) readKey(ctx context.Context) error { case ee := <-f.termEventsChan: e = ee case <-ctx.Done(): - return ErrCancel + return ctx.Err() } f.stateMu.Lock() @@ -752,7 +751,7 @@ func (f *finder) find(slice interface{}, itemFunc func(i int) string, opts []Opt for { select { case <-ctx.Done(): - return nil, ErrCancel + return nil, ctx.Err() default: f.draw(10 * time.Millisecond) diff --git a/fuzzyfinder_test.go b/fuzzyfinder_test.go index 9fcfa84..2bc7dd5 100644 --- a/fuzzyfinder_test.go +++ b/fuzzyfinder_test.go @@ -464,7 +464,7 @@ func TestFind_withContext(t *testing.T) { }, fuzzyfinder.WithContext(cancelledCtx), ) - if !errors.Is(err, fuzzyfinder.ErrCancel) { + if !errors.Is(err, context.Canceled) { t.Fatalf("Find must return ErrAbort, but got '%s'", err) } From 0af14097ddcd5d12c84e61e0044744facae794a1 Mon Sep 17 00:00:00 2001 From: Abdullah Ahsan Date: Tue, 22 Nov 2022 00:12:22 +0100 Subject: [PATCH 4/4] Update fuzzyfinder.go Co-authored-by: ktr --- fuzzyfinder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzzyfinder.go b/fuzzyfinder.go index 3aab70c..78e77da 100644 --- a/fuzzyfinder.go +++ b/fuzzyfinder.go @@ -449,7 +449,7 @@ func (f *finder) draw(d time.Duration) { // readKey reads a key input. // It returns ErrAbort if esc, CTRL-C or CTRL-D keys are inputted, -// errEntered in case of enter key, and ErrCancel when the passed +// errEntered in case of enter key, and a context error when the passed // context is cancelled. func (f *finder) readKey(ctx context.Context) error { f.stateMu.RLock()