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
2021-02-07 16:48:23 +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
2021-02-07 16:48:23 +00:00
// 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" ,
2021-02-07 16:48:23 +00:00
//"--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
}
2021-02-07 16:48:23 +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 ( ) )
}
2021-01-24 13:10:47 +00:00
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-02-07 16:48:23 +00:00
2021-01-23 14:24:08 +00:00
if err != nil {
2021-02-07 16:48:23 +00:00
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 {
2021-02-07 16:48:23 +00:00
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.
2021-02-07 16:48:23 +00:00
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
}