diff --git a/adapter/fzf/finder.go b/adapter/fzf/finder.go index 5f07f32..b717bad 100644 --- a/adapter/fzf/finder.go +++ b/adapter/fzf/finder.go @@ -1,29 +1,44 @@ package fzf import ( + "fmt" "os" "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/core/style" + "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/opt" stringsutil "github.com/mickael-menu/zk/util/strings" ) // NoteFinder wraps a note.Finder and filters its result interactively using fzf. type NoteFinder struct { + opts NoteFinderOpts finder note.Finder styler style.Styler } -func NewNoteFinder(finder note.Finder, styler style.Styler) *NoteFinder { - return &NoteFinder{finder, styler} +type NoteFinderOpts struct { + // Indicates whether fzf is opened for every query, even if empty. + AlwaysFilter bool + // When non nil, a "create new note from query" binding will be added to + // fzf to create a note in this directory. + NewNoteDir *zk.Dir +} + +func NewNoteFinder(opts NoteFinderOpts, finder note.Finder, styler style.Styler) *NoteFinder { + return &NoteFinder{ + opts: opts, + finder: finder, + styler: styler, + } } func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) { isInteractive, opts := popInteractiveFilter(opts) matches, err := f.finder.Find(opts) - if !isInteractive || err != nil || len(matches) == 0 { + if !isInteractive || err != nil || (!f.opts.AlwaysFilter && len(matches) == 0) { return matches, err } @@ -34,10 +49,26 @@ func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) { return selectedMatches, err } + bindings := []Binding{} + + if dir := f.opts.NewNoteDir; dir != nil { + suffix := "" + if dir.Name != "" { + suffix = " in " + dir.Name + "/" + } + + bindings = append(bindings, Binding{ + Keys: "Ctrl-N", + Description: "create a note with the query as title" + suffix, + Action: fmt.Sprintf("abort+execute(%s new %s --title {q} < /dev/tty > /dev/tty)", zkBin, dir.Path), + }) + } + fzf, err := New(Opts{ // PreviewCmd: opt.NewString("bat -p --theme Nord --color always {1}"), PreviewCmd: opt.NewString(zkBin + " list -f {{raw-content}} {1}"), Padding: 2, + Bindings: bindings, }) if err != nil { return selectedMatches, err diff --git a/adapter/fzf/fzf.go b/adapter/fzf/fzf.go index da637d6..e734178 100644 --- a/adapter/fzf/fzf.go +++ b/adapter/fzf/fzf.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/util/errors" "github.com/mickael-menu/zk/util/opt" stringsutil "github.com/mickael-menu/zk/util/strings" @@ -27,6 +28,18 @@ type Opts struct { 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 } // Fzf filters a set of fields using fzf. @@ -65,14 +78,30 @@ func New(opts Opts) (*Fzf, error) { "--tabstop", "4", "--height", "100%", "--layout", "reverse", - // FIXME: Use it to create a new note? Like notational velocity - // "--print-query", + //"--info", "inline", // Make sure the path and titles are always visible "--no-hscroll", // Don't highlight search terms "--color", "hl:-1,hl+:-1", "--preview-window", "wrap", } + + 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, ",")) + } + if !opts.PreviewCmd.IsNull() { args = append(args, "--preview", opts.PreviewCmd.String()) } @@ -108,14 +137,19 @@ func New(opts Opts) (*Fzf, error) { }() output, err := cmd.Output() + if err != nil { - if err, ok := err.(*exec.ExitError); ok && - err.ExitCode() != exitInterrupted && - err.ExitCode() != exitNoMatch { + exitErr, ok := err.(*exec.ExitError) + switch { + case ok && exitErr.ExitCode() == exitInterrupted: + f.err = note.ErrCanceled + case ok && exitErr.ExitCode() == exitNoMatch: + break + default: 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(string(output)) + f.parseSelection(output) } }() @@ -123,7 +157,7 @@ func New(opts Opts) (*Fzf, error) { } // parseSelection extracts the fields from fzf's output. -func (f *Fzf) parseSelection(output string) { +func (f *Fzf) parseSelection(output []byte) { f.selection = make([][]string, 0) lines := stringsutil.SplitLines(string(output)) for _, line := range lines { diff --git a/cmd/container.go b/cmd/container.go index b12af73..6bf860c 100644 --- a/cmd/container.go +++ b/cmd/container.go @@ -9,7 +9,6 @@ import ( "github.com/mickael-menu/zk/adapter/markdown" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/adapter/term" - "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util" "github.com/mickael-menu/zk/util/date" @@ -58,9 +57,9 @@ func (c *Container) Parser() *markdown.Parser { return markdown.NewParser() } -func (c *Container) NoteFinder(tx sqlite.Transaction) note.Finder { +func (c *Container) NoteFinder(tx sqlite.Transaction, opts fzf.NoteFinderOpts) *fzf.NoteFinder { notes := sqlite.NewNoteDAO(tx, c.Logger) - return fzf.NewNoteFinder(notes, c.Terminal) + return fzf.NewNoteFinder(opts, notes, c.Terminal) } // Database returns the DB instance for the given slip box, after executing any diff --git a/cmd/edit.go b/cmd/edit.go index 689501a..3d7c20a 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -4,16 +4,18 @@ import ( "fmt" "path/filepath" + "github.com/mickael-menu/zk/adapter/fzf" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/core/note" + "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/errors" ) // Edit opens notes matching a set of criteria with the user editor. type Edit struct { - Filtering `embed` - Sorting `embed` - Force bool `help:"Don't confirm before editing many notes at the same time" short:"f"` + Filtering + Sorting + Force bool `help:"Don't confirm before editing many notes at the same time" short:"f"` } func (cmd *Edit) Run(container *Container) error { @@ -34,10 +36,17 @@ func (cmd *Edit) Run(container *Container) error { var notes []note.Match err = db.WithTransaction(func(tx sqlite.Transaction) error { - notes, err = container.NoteFinder(tx).Find(*opts) + finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ + AlwaysFilter: true, + NewNoteDir: cmd.newNoteDir(zk), + }) + notes, err = finder.Find(*opts) return err }) if err != nil { + if err == note.ErrCanceled { + return nil + } return err } @@ -60,9 +69,29 @@ func (cmd *Edit) Run(container *Container) error { } note.Edit(zk, paths...) + } else { fmt.Println("Found 0 note") } return err } + +// newNoteDir returns the directory in which to create a new note when the fzf +// binding is triggered. +func (cmd *Edit) newNoteDir(zk *zk.Zk) *zk.Dir { + switch len(cmd.Path) { + case 0: + dir := zk.RootDir() + return &dir + case 1: + dir, err := zk.DirAt(cmd.Path[0]) + if err != nil { + return nil + } + return dir + default: + // More than one directory, it's ambiguous for the "new note" fzf binding. + return nil + } +} diff --git a/cmd/list.go b/cmd/list.go index 2f5196b..b954a21 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -5,6 +5,7 @@ import ( "io" "os" + "github.com/mickael-menu/zk/adapter/fzf" "github.com/mickael-menu/zk/adapter/sqlite" "github.com/mickael-menu/zk/core/note" "github.com/mickael-menu/zk/util/errors" @@ -54,10 +55,16 @@ func (cmd *List) Run(container *Container) error { var notes []note.Match err = db.WithTransaction(func(tx sqlite.Transaction) error { - notes, err = container.NoteFinder(tx).Find(*opts) + finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ + AlwaysFilter: false, + }) + notes, err = finder.Find(*opts) return err }) if err != nil { + if err == note.ErrCanceled { + return nil + } return err } diff --git a/core/note/edit.go b/core/note/edit.go index 5455f13..5852d7b 100644 --- a/core/note/edit.go +++ b/core/note/edit.go @@ -3,11 +3,11 @@ package note import ( "fmt" "os" - "os/exec" + "strings" - "github.com/kballard/go-shellquote" "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/errors" + executil "github.com/mickael-menu/zk/util/exec" "github.com/mickael-menu/zk/util/opt" osutil "github.com/mickael-menu/zk/util/os" ) @@ -19,22 +19,12 @@ func Edit(zk *zk.Zk, paths ...string) error { return fmt.Errorf("no editor set in config") } - wrap := errors.Wrapperf("failed to launch editor: %v", editor) - - args, err := shellquote.Split(editor.String()) - if err != nil { - return wrap(err) - } - if len(args) == 0 { - return wrap(fmt.Errorf("editor command is not valid: %v", editor)) - } - args = append(args, paths...) - - cmd := exec.Command(args[0], args[1:]...) + cmd := executil.CommandFromString(editor.String(), paths...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr - return wrap(cmd.Run()) + return errors.Wrapf(cmd.Run(), "failed to launch editor: %s %s", editor, strings.Join(paths, " ")) } // editor returns the editor command to use to edit a note. diff --git a/core/note/find.go b/core/note/find.go index c102ba4..1cb777f 100644 --- a/core/note/find.go +++ b/core/note/find.go @@ -1,12 +1,16 @@ package note import ( + "errors" "fmt" "strings" "time" "unicode/utf8" ) +// ErrCanceled is returned when the user cancelled an operation. +var ErrCanceled = errors.New("canceled") + // Finder retrieves notes matching the given options. // // Returns the number of matches found. diff --git a/core/zk/zk.go b/core/zk/zk.go index 222d58e..e973ab4 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -144,6 +144,15 @@ func (zk *Zk) RelPath(path string) (string, error) { return path, nil } +// RootDir returns the root Dir for this slip box. +func (zk *Zk) RootDir() Dir { + return Dir{ + Name: "", + Path: zk.Path, + Config: zk.Config.DirConfig, + } +} + // DirAt returns a Dir representation of the slip box directory at the given path. func (zk *Zk) DirAt(path string, overrides ...ConfigOverrides) (*Dir, error) { path, err := filepath.Abs(path) diff --git a/core/zk/zk_test.go b/core/zk/zk_test.go index 49f49e2..323f9e9 100644 --- a/core/zk/zk_test.go +++ b/core/zk/zk_test.go @@ -16,6 +16,17 @@ func TestDBPath(t *testing.T) { assert.Equal(t, zk.DBPath(), filepath.Join(wd, ".zk/data.db")) } +func TestRootDir(t *testing.T) { + wd, _ := os.Getwd() + zk := &Zk{Path: wd} + + assert.Equal(t, zk.RootDir(), Dir{ + Name: "", + Path: wd, + Config: zk.Config.DirConfig, + }) +} + func TestRelativePathFromGivenPath(t *testing.T) { // The tests are relative to the working directory, for convenience. wd, _ := os.Getwd() diff --git a/go.mod b/go.mod index 8977dc9..846bb3a 100644 --- a/go.mod +++ b/go.mod @@ -12,21 +12,21 @@ require ( github.com/gosimple/slug v1.9.0 github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/lestrrat-go/strftime v1.0.3 + github.com/lestrrat-go/strftime v1.0.4 github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-runewidth v0.0.10 // indirect github.com/mattn/go-sqlite3 v1.14.6 github.com/mickael-menu/pretty v0.2.3 github.com/pelletier/go-toml v1.8.1 + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.6.2 // indirect github.com/rvflash/elapsed v0.2.0 - github.com/schollz/progressbar/v3 v3.7.3 + github.com/schollz/progressbar/v3 v3.7.4 github.com/tebeka/strftime v0.1.5 // indirect github.com/tj/go-naturaldate v1.3.0 - github.com/yuin/goldmark v1.3.1 + github.com/yuin/goldmark v1.3.2 github.com/yuin/goldmark-meta v1.0.0 - golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 // indirect gopkg.in/djherbis/times.v1 v1.2.0 gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 06825a1..e934e49 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2t github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= github.com/lestrrat-go/strftime v1.0.3 h1:qqOPU7y+TM8Y803I8fG9c/DyKG3xH/xkng6keC1015Q= github.com/lestrrat-go/strftime v1.0.3/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= +github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9BHElA8= +github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -125,6 +127,8 @@ github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNC github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= @@ -145,6 +149,8 @@ github.com/rvflash/elapsed v0.2.0/go.mod h1:sgjohdXO66LHVgIEQpO92eQjDWyZ5twX1ow1 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/schollz/progressbar/v3 v3.7.3 h1:U0etV6FzAPBne0ZqoWwThp7FEdfcTX2lHzQYh5B7scE= github.com/schollz/progressbar/v3 v3.7.3/go.mod h1:fBsumCeOE+GOuGKY1JldFX0eRT6gkw3sw9eZTt2bFgE= +github.com/schollz/progressbar/v3 v3.7.4 h1:G2HfclnGJR2HtTOmFkERQcRqo9J20asOFiuD6AnI5EQ= +github.com/schollz/progressbar/v3 v3.7.4/go.mod h1:1H8m5kMPW6q5fyjpDqtBHW1JT22mu2NwHQ1ApuCPh/8= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -168,6 +174,8 @@ github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmy github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I= github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.3.2 h1:YjHC5TgyMmHpicTgEqDN0Q96Xo8K6tLXPnmNOHXCgs0= +github.com/yuin/goldmark v1.3.2/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark-meta v1.0.0 h1:ScsatUIT2gFS6azqzLGUjgOnELsBOxMXerM3ogdJhAM= github.com/yuin/goldmark-meta v1.0.0/go.mod h1:zsNNOrZ4nLuyHAJeLQEZcQat8dm70SmB2kHbls092Gc= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= @@ -210,6 +218,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/util/exec/exec_unix.go b/util/exec/exec_unix.go index edf12ad..76c7aaf 100644 --- a/util/exec/exec_unix.go +++ b/util/exec/exec_unix.go @@ -5,6 +5,8 @@ package exec import ( "os" "os/exec" + + "github.com/kballard/go-shellquote" ) // CommandFromString returns a Cmd running the given command with $SHELL. @@ -13,6 +15,6 @@ func CommandFromString(command string, args ...string) *exec.Cmd { if len(shell) == 0 { shell = "sh" } - args = append([]string{"-c", command}, args...) + args = append([]string{"-c", command + " " + shellquote.Join(args...)}) return exec.Command(shell, args...) }