zk/adapter/fzf/fzf.go

208 lines
4.5 KiB
Go
Raw Normal View History

2021-01-23 12:29:14 +00:00
package fzf
import (
2021-01-23 14:24:08 +00:00
"fmt"
2021-01-23 12:29:14 +00:00
"io"
"os"
"os/exec"
2021-01-23 14:24:08 +00:00
"strings"
"sync"
2021-01-23 12:29:14 +00:00
"github.com/mickael-menu/zk/core/note"
2021-01-23 14:24:08 +00:00
"github.com/mickael-menu/zk/util/errors"
"github.com/mickael-menu/zk/util/opt"
stringsutil "github.com/mickael-menu/zk/util/strings"
2021-01-23 12:29:14 +00:00
)
2021-01-23 14:24:08 +00:00
// fzf exit codes
var (
exitInterrupted = 130
exitNoMatch = 1
)
// Opts holds the options used to run fzf.
type Opts struct {
// Preview command executed by fzf when hovering a line.
PreviewCmd opt.String
// Amount of space between two non-empty fields.
Padding int
// Delimiter used by fzf between fields.
Delimiter string
// List of key bindings enabled in fzf.
Bindings []Binding
}
// Binding represents a keyboard shortcut bound to an action in fzf.
type Binding struct {
// Keyboard shortcut, e.g. `ctrl-n`.
Keys string
// fzf action, see `man fzf`.
Action string
// Description which will be displayed as a fzf header if not empty.
Description string
2021-01-23 14:24:08 +00:00
}
// Fzf filters a set of fields using fzf.
//
// After adding all the fields with Add, use Selection to get the filtered
// results.
type Fzf struct {
opts Opts
// Fields selection or error result.
err error
selection [][]string
done chan bool
cmd *exec.Cmd
pipe io.WriteCloser
closeOnce sync.Once
}
// New runs a fzf instance.
//
// To show a preview of each line, provide a previewCmd which will be executed
// by fzf.
func New(opts Opts) (*Fzf, error) {
// \x01 is a convenient delimiter because not visible in the output and
// most likely not part of the fields themselves.
if opts.Delimiter == "" {
opts.Delimiter = "\x01"
2021-01-23 12:29:14 +00:00
}
2021-01-23 14:24:08 +00:00
args := []string{
"--delimiter", opts.Delimiter,
2021-01-23 12:29:14 +00:00
"--tiebreak", "begin",
"--ansi",
"--exact",
2021-01-23 14:24:08 +00:00
"--tabstop", "4",
2021-01-23 12:29:14 +00:00
"--height", "100%",
2021-01-23 14:24:08 +00:00
"--layout", "reverse",
//"--info", "inline",
2021-01-23 12:29:14 +00:00
// Make sure the path and titles are always visible
"--no-hscroll",
// Don't highlight search terms
"--color", "hl:-1,hl+:-1",
2021-01-25 20:44:44 +00:00
"--preview-window", "wrap",
2021-01-23 14:24:08 +00:00
}
header := ""
binds := []string{}
for _, binding := range opts.Bindings {
if binding.Description != "" {
header += binding.Keys + ": " + binding.Description + "\n"
}
binds = append(binds, binding.Keys+":"+binding.Action)
}
if header != "" {
args = append(args, "--header", strings.TrimSpace(header))
}
if len(binds) > 0 {
args = append(args, "--bind", strings.Join(binds, ","))
}
2021-01-23 14:24:08 +00:00
if !opts.PreviewCmd.IsNull() {
args = append(args, "--preview", opts.PreviewCmd.String())
}
fzfPath, err := exec.LookPath("fzf")
if err != nil {
return nil, fmt.Errorf("interactive mode requires fzf, try without --interactive or install fzf from https://github.com/junegunn/fzf")
}
cmd := exec.Command(fzfPath, args...)
2021-01-23 12:29:14 +00:00
cmd.Stderr = os.Stderr
2021-01-23 14:24:08 +00:00
pipe, err := cmd.StdinPipe()
2021-01-23 12:29:14 +00:00
if err != nil {
2021-01-23 14:24:08 +00:00
return nil, err
}
done := make(chan bool)
f := Fzf{
opts: opts,
cmd: cmd,
pipe: pipe,
closeOnce: sync.Once{},
done: done,
selection: make([][]string, 0),
2021-01-23 12:29:14 +00:00
}
go func() {
2021-01-23 14:24:08 +00:00
defer func() {
close(done)
f.close()
}()
output, err := cmd.Output()
2021-01-23 14:24:08 +00:00
if err != nil {
exitErr, ok := err.(*exec.ExitError)
switch {
case ok && exitErr.ExitCode() == exitInterrupted:
f.err = note.ErrCanceled
case ok && exitErr.ExitCode() == exitNoMatch:
break
default:
2021-01-23 14:24:08 +00:00
f.err = errors.Wrap(err, "failed to filter interactively the output with fzf, try again without --interactive or make sure you have a working fzf installation")
}
} else {
f.parseSelection(output)
2021-01-23 14:24:08 +00:00
}
2021-01-23 12:29:14 +00:00
}()
2021-01-23 14:24:08 +00:00
return &f, nil
}
// parseSelection extracts the fields from fzf's output.
func (f *Fzf) parseSelection(output []byte) {
2021-01-23 14:24:08 +00:00
f.selection = make([][]string, 0)
lines := stringsutil.SplitLines(string(output))
for _, line := range lines {
fields := strings.Split(line, f.opts.Delimiter)
// Trim padding
for i, field := range fields {
fields[i] = strings.TrimSpace(field)
}
f.selection = append(f.selection, fields)
2021-01-23 12:29:14 +00:00
}
2021-01-23 14:24:08 +00:00
}
// Add appends a new line of fields to fzf input.
func (f *Fzf) Add(fields []string) error {
line := ""
for i, field := range fields {
if i > 0 {
line += f.opts.Delimiter
if field != "" && f.opts.Padding > 0 {
line += strings.Repeat(" ", f.opts.Padding)
}
}
line += field
2021-01-23 12:29:14 +00:00
}
2021-01-23 14:24:08 +00:00
if line == "" {
return nil
}
_, err := fmt.Fprintln(f.pipe, line)
return err
}
// Selection returns the field lines selected by the user through fzf.
func (f *Fzf) Selection() ([][]string, error) {
f.close()
<-f.done
return f.selection, f.err
}
2021-01-23 12:29:14 +00:00
2021-01-23 14:24:08 +00:00
func (f *Fzf) close() error {
var err error
f.closeOnce.Do(func() {
err = f.pipe.Close()
})
return err
2021-01-23 12:29:14 +00:00
}