From b86a74d1e9e425b92bcb01af594201043fe51182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 15 Jan 2021 22:21:33 +0100 Subject: [PATCH] Use the system pager to paginate list results --- cmd/list.go | 27 +++++--- util/pager/pager.go | 125 +++++++++++++++++++++++++++++++++++ util/strings/strings.go | 9 +++ util/strings/strings_test.go | 15 +++++ 4 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 util/pager/pager.go diff --git a/cmd/list.go b/cmd/list.go index 465525f..a908d72 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -9,6 +9,8 @@ import ( "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util/errors" "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/util/pager" + "github.com/mickael-menu/zk/util/strings" "github.com/tj/go-naturaldate" ) @@ -26,6 +28,7 @@ type List struct { ModifiedAfter string `help:"Show only the notes modified after the given date" placeholder:""` Exclude []string `help:"Excludes notes matching the given file path pattern from the list" short:"x" placeholder:""` Sort []string `help:"Sort the notes by the given criterion" short:"s" placeholder:""` + NoPager bool `help:"Do not pipe zk output into a pager" short:"P"` } func (cmd *List) Run(container *Container) error { @@ -44,8 +47,10 @@ func (cmd *List) Run(container *Container) error { return err } + logger := container.Logger + return db.WithTransaction(func(tx sqlite.Transaction) error { - notes := sqlite.NewNoteDAO(tx, container.Logger) + notes := sqlite.NewNoteDAO(tx, logger) deps := note.ListDeps{ BasePath: zk.Path, @@ -53,9 +58,20 @@ func (cmd *List) Run(container *Container) error { Templates: container.TemplateLoader(zk.Config.Lang), } - count, err := note.List(*opts, deps, printNote) + p := pager.PassthroughPager + if !cmd.NoPager { + p, err = pager.New(logger) + if err != nil { + return err + } + } + + count, err := note.List(*opts, deps, p.WriteString) + + p.Close() + if err == nil { - fmt.Printf("\nFound %d result(s)\n", count) + fmt.Printf("\nFound %d %s\n", count, strings.Pluralize("result", count)) } return err @@ -177,11 +193,6 @@ func relPaths(zk *zk.Zk, paths []string) ([]string, bool) { return relPaths, len(relPaths) > 0 } -func printNote(note string) error { - _, err := fmt.Println(note) - return err -} - func parseDate(date string) (time.Time, error) { // FIXME: support years return naturaldate.Parse(date, time.Now().UTC(), naturaldate.WithDirection(naturaldate.Past)) diff --git a/util/pager/pager.go b/util/pager/pager.go new file mode 100644 index 0000000..6adb509 --- /dev/null +++ b/util/pager/pager.go @@ -0,0 +1,125 @@ +package pager + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/kballard/go-shellquote" + "github.com/mickael-menu/zk/util" + "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/util/opt" + osutil "github.com/mickael-menu/zk/util/os" +) + +// Pager writes text to a TTY using the user's pager. +type Pager struct { + io.WriteCloser + done chan bool + isCloseable bool + closeOnce sync.Once +} + +// PassthroughPager is a Pager writing the content directly to stdout without +// pagination. +var PassthroughPager = &Pager{ + WriteCloser: os.Stdout, + isCloseable: false, +} + +// New creates a pager.Pager to be used to write a paginated text to the TTY. +func New(logger util.Logger) (*Pager, error) { + wrap := errors.Wrapper("failed to paginate the output, try again with --no-pager or fix your PAGER environment variable") + + pagerCmd := locatePager() + if pagerCmd.IsNull() { + return PassthroughPager, nil + } + + args, err := shellquote.Split(pagerCmd.String()) + if err != nil { + return nil, wrap(err) + } + cmd := exec.Command(args[0], args[1:]...) + + r, w, err := os.Pipe() + if err != nil { + return nil, wrap(err) + } + + cmd.Stdin = r + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + done := make(chan bool) + pager := Pager{ + WriteCloser: w, + done: done, + isCloseable: true, + closeOnce: sync.Once{}, + } + + go func() { + defer close(done) + + err := cmd.Run() + if err != nil { + logger.Err(wrap(err)) + os.Exit(1) + } + }() + + return &pager, nil +} + +// Close terminates the pager, waiting for the process to be finished before returning. +// +// We make sure Close is called only once, since we don't know how it is +// implemented in underlying writers. +func (p *Pager) Close() error { + if !p.isCloseable { + return nil + } + + var err error + p.closeOnce.Do(func() { + err = p.WriteCloser.Close() + }) + <-p.done + return err +} + +// WriteString sends the given text to the pager, ending with a newline. +func (p *Pager) WriteString(text string) error { + _, err := fmt.Fprintln(p, text) + return err +} + +func locatePager() opt.String { + return osutil.GetOptEnv("ZK_PAGER"). + Or(osutil.GetOptEnv("PAGER")). + Or(locateDefaultPager()) +} + +var defaultPagers = []string{ + "less -FIRX", "more -R", +} + +func locateDefaultPager() opt.String { + for _, pager := range defaultPagers { + parts, err := shellquote.Split(pager) + if err != nil { + continue + } + + pager, err := exec.LookPath(parts[0]) + parts[0] = pager + if err == nil { + return opt.NewNotEmptyString(strings.Join(parts, " ")) + } + } + return opt.NullString +} diff --git a/util/strings/strings.go b/util/strings/strings.go index c3b661e..a26afee 100644 --- a/util/strings/strings.go +++ b/util/strings/strings.go @@ -16,3 +16,12 @@ func Prepend(text string, prefix string) string { return strings.Join(append([]string{""}, lines...), prefix) } + +// Pluralize adds an `s` at the end of word if the count is more than 1. +func Pluralize(word string, count int) string { + if word == "" || (count >= -1 && count <= 1) { + return word + } else { + return word + "s" + } +} diff --git a/util/strings/strings_test.go b/util/strings/strings_test.go index 9335a11..5457073 100644 --- a/util/strings/strings_test.go +++ b/util/strings/strings_test.go @@ -17,3 +17,18 @@ func TestPrepend(t *testing.T) { test("One line\nTwo lines\nThree lines", "> ", "> One line\n> Two lines\n> Three lines") test("Newline\n", "> ", "> Newline\n") } + +func TestPluralize(t *testing.T) { + test := func(word string, count int, expected string) { + assert.Equal(t, Pluralize(word, count), expected) + } + + test("", 1, "") + test("", 2, "") + test("word", -2, "words") + test("word", -1, "word") + test("word", 0, "word") + test("word", 1, "word") + test("word", 2, "words") + test("word", 1000, "words") +}