Add a fzf binding to create a new note with the search query from the edit command

pull/6/head
Mickaël Menu 3 years ago
parent 76fb5f67aa
commit 7e3e2cbd20
No known key found for this signature in database
GPG Key ID: 53D73664CD359895

@ -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

@ -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 {

@ -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

@ -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
}
}

@ -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
}

@ -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.

@ -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.

@ -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)

@ -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()

@ -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
)

@ -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=

@ -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...)
}

Loading…
Cancel
Save