termui: allow to change the bug query

Michael Muré created

Change summary

cache/repo_cache.go   |  7 +++++--
commands/root.go      |  6 +++++-
input/input.go        | 32 ++++++++++++++++++++++++++++++++
termui/bug_table.go   | 37 ++++++++++++++++++++++++++-----------
termui/input_popup.go | 14 +++++++++++---
termui/termui.go      | 34 ++++++++++++++++++++++++++++++++++
6 files changed, 113 insertions(+), 17 deletions(-)

Detailed changes

cache/repo_cache.go 🔗

@@ -19,9 +19,12 @@ import (
 )
 
 type RepoCache struct {
-	repo     repository.Repo
+	// the underlying repo
+	repo repository.Repo
+	// excerpt of bugs data for all bugs
 	excerpts map[string]*BugExcerpt
-	bugs     map[string]*BugCache
+	// bug loaded in memory
+	bugs map[string]*BugCache
 }
 
 func NewRepoCache(r repository.Repo) (*RepoCache, error) {

commands/root.go 🔗

@@ -24,7 +24,9 @@ var RootCmd = &cobra.Command{
 
 It use the same internal storage so it doesn't pollute your project. As you would do with commits and branches, you can push your bugs to the same git remote your are already using to collaborate with other peoples.`,
 
-	// Force the execution of the PreRun while still displaying the help
+	// For the root command, force the execution of the PreRun
+	// even if we just display the help. This is to make sure that we check
+	// the repository and give the user early feedback.
 	Run: func(cmd *cobra.Command, args []string) {
 		cmd.Help()
 	},
@@ -35,6 +37,8 @@ It use the same internal storage so it doesn't pollute your project. As you woul
 
 	DisableAutoGenTag: true,
 
+	// Custom bash code to connect the git completion for "git bug" to the
+	// git-bug completion for "git-bug"
 	BashCompletionFunction: `
 _git_bug() {
     __start_git-bug "$@"

input/input.go 🔗

@@ -149,6 +149,38 @@ func BugTitleEditorInput(repo repository.Repo, preTitle string) (string, error)
 	return title, nil
 }
 
+const queryTemplate = `%s
+
+# Please edit the bug query.
+# Lines starting with '#' will be ignored, and an empty query aborts the operation.
+`
+
+// QueryEditorInput will open the default editor in the terminal with a
+// template for the user to fill. The file is then processed to extract a query.
+func QueryEditorInput(repo repository.Repo, preQuery string) (string, error) {
+	template := fmt.Sprintf(queryTemplate, preQuery)
+	raw, err := launchEditorWithTemplate(repo, messageFilename, template)
+
+	if err != nil {
+		return "", err
+	}
+
+	lines := strings.Split(raw, "\n")
+
+	for _, line := range lines {
+		if strings.HasPrefix(line, "#") {
+			continue
+		}
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+		return trimmed, nil
+	}
+
+	return "", nil
+}
+
 // launchEditorWithTemplate will launch an editor as launchEditor do, but with a
 // provided template.
 func launchEditorWithTemplate(repo repository.Repo, fileName string, template string) (string, error) {

termui/bug_table.go 🔗

@@ -16,10 +16,12 @@ const bugTableHeaderView = "bugTableHeaderView"
 const bugTableFooterView = "bugTableFooterView"
 const bugTableInstructionView = "bugTableInstructionView"
 
-const remote = "origin"
+const defaultRemote = "origin"
+const defaultQuery = "status:open"
 
 type bugTable struct {
 	repo         *cache.RepoCache
+	queryStr     string
 	query        *cache.Query
 	allIds       []string
 	bugs         []*cache.BugCache
@@ -28,12 +30,15 @@ type bugTable struct {
 }
 
 func newBugTable(c *cache.RepoCache) *bugTable {
+	query, err := cache.ParseQuery(defaultQuery)
+	if err != nil {
+		panic(err)
+	}
+
 	return &bugTable{
-		repo: c,
-		query: &cache.Query{
-			OrderBy:        cache.OrderByCreation,
-			OrderDirection: cache.OrderAscending,
-		},
+		repo:         c,
+		query:        query,
+		queryStr:     defaultQuery,
 		pageCursor:   0,
 		selectCursor: 0,
 	}
@@ -197,6 +202,12 @@ func (bt *bugTable) keybindings(g *gocui.Gui) error {
 		return err
 	}
 
+	// Query
+	if err := g.SetKeybinding(bugTableView, 'q', gocui.ModNone,
+		bt.changeQuery); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -390,10 +401,10 @@ func (bt *bugTable) openBug(g *gocui.Gui, v *gocui.View) error {
 func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
 	// Note: this is very hacky
 
-	ui.msgPopup.Activate("Pull from remote "+remote, "...")
+	ui.msgPopup.Activate("Pull from remote "+defaultRemote, "...")
 
 	go func() {
-		stdout, err := bt.repo.Fetch(remote)
+		stdout, err := bt.repo.Fetch(defaultRemote)
 
 		if err != nil {
 			g.Update(func(gui *gocui.Gui) error {
@@ -410,7 +421,7 @@ func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
 		var buffer bytes.Buffer
 		beginLine := ""
 
-		for merge := range bt.repo.MergeAll(remote) {
+		for merge := range bt.repo.MergeAll(defaultRemote) {
 			if merge.Status == bug.MsgMergeNothing {
 				continue
 			}
@@ -447,11 +458,11 @@ func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
 }
 
 func (bt *bugTable) push(g *gocui.Gui, v *gocui.View) error {
-	ui.msgPopup.Activate("Push to remote "+remote, "...")
+	ui.msgPopup.Activate("Push to remote "+defaultRemote, "...")
 
 	go func() {
 		// TODO: make the remote configurable
-		stdout, err := bt.repo.Push(remote)
+		stdout, err := bt.repo.Push(defaultRemote)
 
 		if err != nil {
 			g.Update(func(gui *gocui.Gui) error {
@@ -468,3 +479,7 @@ func (bt *bugTable) push(g *gocui.Gui, v *gocui.View) error {
 
 	return nil
 }
+
+func (bt *bugTable) changeQuery(g *gocui.Gui, v *gocui.View) error {
+	return editQueryWithEditor(bt)
+}

termui/input_popup.go 🔗

@@ -8,10 +8,12 @@ import (
 
 const inputPopupView = "inputPopupView"
 
+// inputPopup is a simple popup with an input field
 type inputPopup struct {
-	active bool
-	title  string
-	c      chan string
+	active  bool
+	title   string
+	preload string
+	c       chan string
 }
 
 func newInputPopup() *inputPopup {
@@ -53,6 +55,7 @@ func (ip *inputPopup) layout(g *gocui.Gui) error {
 		v.Frame = true
 		v.Title = ip.title
 		v.Editable = true
+		v.Write([]byte(ip.preload))
 	}
 
 	if _, err := g.SetCurrentView(inputPopupView); err != nil {
@@ -88,6 +91,11 @@ func (ip *inputPopup) validate(g *gocui.Gui, v *gocui.View) error {
 	return nil
 }
 
+func (ip *inputPopup) ActivateWithContent(title string, content string) <-chan string {
+	ip.preload = content
+	return ip.Activate(title)
+}
+
 func (ip *inputPopup) Activate(title string) <-chan string {
 	ip.title = title
 	ip.active = true

termui/termui.go 🔗

@@ -262,6 +262,40 @@ func setTitleWithEditor(bug *cache.BugCache) error {
 	return errTerminateMainloop
 }
 
+func editQueryWithEditor(bt *bugTable) error {
+	// This is somewhat hacky.
+	// As there is no way to pause gocui, run the editor and restart gocui,
+	// we have to stop it entirely and start a new one later.
+	//
+	// - an error channel is used to route the returned error of this new
+	// 		instance into the original launch function
+	// - a custom error (errTerminateMainloop) is used to terminate the original
+	//		instance's mainLoop. This error is then filtered.
+
+	ui.g.Close()
+	ui.g = nil
+
+	queryStr, err := input.QueryEditorInput(bt.repo.Repository(), bt.queryStr)
+
+	if err != nil {
+		return err
+	}
+
+	bt.queryStr = queryStr
+
+	query, err := cache.ParseQuery(queryStr)
+
+	if err != nil {
+		ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
+	} else {
+		bt.query = query
+	}
+
+	initGui(nil)
+
+	return errTerminateMainloop
+}
+
 func maxInt(a, b int) int {
 	if a > b {
 		return a