bug_table.go

  1package termui
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"strings"
  7	"time"
  8
  9	"github.com/MichaelMure/go-term-text"
 10	"github.com/awesome-gocui/gocui"
 11	"github.com/dustin/go-humanize"
 12
 13	"github.com/MichaelMure/git-bug/cache"
 14	"github.com/MichaelMure/git-bug/entity"
 15	"github.com/MichaelMure/git-bug/query"
 16	"github.com/MichaelMure/git-bug/util/colors"
 17)
 18
 19const bugTableView = "bugTableView"
 20const bugTableHeaderView = "bugTableHeaderView"
 21const bugTableFooterView = "bugTableFooterView"
 22const bugTableInstructionView = "bugTableInstructionView"
 23
 24const defaultRemote = "origin"
 25const defaultQuery = "status:open"
 26
 27type bugTable struct {
 28	repo         *cache.RepoCache
 29	queryStr     string
 30	query        *query.Query
 31	allIds       []entity.Id
 32	excerpts     []*cache.BugExcerpt
 33	pageCursor   int
 34	selectCursor int
 35}
 36
 37func newBugTable(c *cache.RepoCache) *bugTable {
 38	q, err := query.Parse(defaultQuery)
 39	if err != nil {
 40		panic(err)
 41	}
 42
 43	return &bugTable{
 44		repo:         c,
 45		query:        q,
 46		queryStr:     defaultQuery,
 47		pageCursor:   0,
 48		selectCursor: 0,
 49	}
 50}
 51
 52func (bt *bugTable) layout(g *gocui.Gui) error {
 53	maxX, maxY := g.Size()
 54
 55	if maxY < 4 {
 56		// window too small !
 57		return nil
 58	}
 59
 60	v, err := g.SetView(bugTableHeaderView, -1, -1, maxX, 3, 0)
 61
 62	if err != nil {
 63		if !gocui.IsUnknownView(err) {
 64			return err
 65		}
 66
 67		v.Frame = false
 68	}
 69
 70	v.Clear()
 71	bt.renderHeader(v, maxX)
 72
 73	v, err = g.SetView(bugTableView, -1, 1, maxX, maxY-3, 0)
 74
 75	if err != nil {
 76		if !gocui.IsUnknownView(err) {
 77			return err
 78		}
 79
 80		v.Frame = false
 81		v.SelBgColor = gocui.ColorWhite
 82		v.SelFgColor = gocui.ColorBlack
 83	}
 84
 85	_, viewHeight := v.Size()
 86	err = bt.paginate(viewHeight)
 87	if err != nil {
 88		return err
 89	}
 90
 91	err = bt.cursorClamp(v)
 92	if err != nil {
 93		return err
 94	}
 95
 96	v.Clear()
 97	bt.render(v, maxX)
 98
 99	v, err = g.SetView(bugTableFooterView, -1, maxY-4, maxX, maxY, 0)
100
101	if err != nil {
102		if !gocui.IsUnknownView(err) {
103			return err
104		}
105
106		v.Frame = false
107	}
108
109	v.Clear()
110	bt.renderFooter(v, maxX)
111
112	v, err = g.SetView(bugTableInstructionView, -1, maxY-2, maxX, maxY, 0)
113
114	if err != nil {
115		if !gocui.IsUnknownView(err) {
116			return err
117		}
118
119		v.Frame = false
120		v.BgColor = gocui.ColorBlue
121
122		_, _ = fmt.Fprintf(v, "[q] Quit [s] Search [←↓↑→,hjkl] Navigation [↵] Open bug [n] New bug [i] Pull [o] Push")
123	}
124
125	_, err = g.SetCurrentView(bugTableView)
126	return err
127}
128
129func (bt *bugTable) keybindings(g *gocui.Gui) error {
130	// Quit
131	if err := g.SetKeybinding(bugTableView, 'q', gocui.ModNone, quit); err != nil {
132		return err
133	}
134
135	// Down
136	if err := g.SetKeybinding(bugTableView, 'j', gocui.ModNone,
137		bt.cursorDown); err != nil {
138		return err
139	}
140	if err := g.SetKeybinding(bugTableView, gocui.KeyArrowDown, gocui.ModNone,
141		bt.cursorDown); err != nil {
142		return err
143	}
144	// Up
145	if err := g.SetKeybinding(bugTableView, 'k', gocui.ModNone,
146		bt.cursorUp); err != nil {
147		return err
148	}
149	if err := g.SetKeybinding(bugTableView, gocui.KeyArrowUp, gocui.ModNone,
150		bt.cursorUp); err != nil {
151		return err
152	}
153
154	// Previous page
155	if err := g.SetKeybinding(bugTableView, 'h', gocui.ModNone,
156		bt.previousPage); err != nil {
157		return err
158	}
159	if err := g.SetKeybinding(bugTableView, gocui.KeyArrowLeft, gocui.ModNone,
160		bt.previousPage); err != nil {
161		return err
162	}
163	if err := g.SetKeybinding(bugTableView, gocui.KeyPgup, gocui.ModNone,
164		bt.previousPage); err != nil {
165		return err
166	}
167	// Next page
168	if err := g.SetKeybinding(bugTableView, 'l', gocui.ModNone,
169		bt.nextPage); err != nil {
170		return err
171	}
172	if err := g.SetKeybinding(bugTableView, gocui.KeyArrowRight, gocui.ModNone,
173		bt.nextPage); err != nil {
174		return err
175	}
176	if err := g.SetKeybinding(bugTableView, gocui.KeyPgdn, gocui.ModNone,
177		bt.nextPage); err != nil {
178		return err
179	}
180
181	// New bug
182	if err := g.SetKeybinding(bugTableView, 'n', gocui.ModNone,
183		bt.newBug); err != nil {
184		return err
185	}
186
187	// Open bug
188	if err := g.SetKeybinding(bugTableView, gocui.KeyEnter, gocui.ModNone,
189		bt.openBug); err != nil {
190		return err
191	}
192
193	// Pull
194	if err := g.SetKeybinding(bugTableView, 'i', gocui.ModNone,
195		bt.pull); err != nil {
196		return err
197	}
198
199	// Push
200	if err := g.SetKeybinding(bugTableView, 'o', gocui.ModNone,
201		bt.push); err != nil {
202		return err
203	}
204
205	// Query
206	if err := g.SetKeybinding(bugTableView, 's', gocui.ModNone,
207		bt.changeQuery); err != nil {
208		return err
209	}
210
211	return nil
212}
213
214func (bt *bugTable) disable(g *gocui.Gui) error {
215	if err := g.DeleteView(bugTableView); err != nil && !gocui.IsUnknownView(err) {
216		return err
217	}
218	if err := g.DeleteView(bugTableHeaderView); err != nil && !gocui.IsUnknownView(err) {
219		return err
220	}
221	if err := g.DeleteView(bugTableFooterView); err != nil && !gocui.IsUnknownView(err) {
222		return err
223	}
224	if err := g.DeleteView(bugTableInstructionView); err != nil && !gocui.IsUnknownView(err) {
225		return err
226	}
227	return nil
228}
229
230func (bt *bugTable) paginate(max int) error {
231	bt.allIds = bt.repo.QueryBugs(bt.query)
232
233	return bt.doPaginate(max)
234}
235
236func (bt *bugTable) doPaginate(max int) error {
237	// clamp the cursor
238	bt.pageCursor = maxInt(bt.pageCursor, 0)
239	bt.pageCursor = minInt(bt.pageCursor, len(bt.allIds))
240
241	nb := minInt(len(bt.allIds)-bt.pageCursor, max)
242
243	if nb < 0 {
244		bt.excerpts = []*cache.BugExcerpt{}
245		return nil
246	}
247
248	// slice the data
249	ids := bt.allIds[bt.pageCursor : bt.pageCursor+nb]
250
251	bt.excerpts = make([]*cache.BugExcerpt, len(ids))
252
253	for i, id := range ids {
254		excerpt, err := bt.repo.ResolveBugExcerpt(id)
255		if err != nil {
256			return err
257		}
258
259		bt.excerpts[i] = excerpt
260	}
261
262	return nil
263}
264
265func (bt *bugTable) getTableLength() int {
266	return len(bt.excerpts)
267}
268
269func (bt *bugTable) getColumnWidths(maxX int) map[string]int {
270	m := make(map[string]int)
271	m["id"] = 9
272	m["status"] = 7
273
274	left := maxX - 5 - m["id"] - m["status"]
275
276	m["comments"] = 10
277	left -= m["comments"]
278	m["lastEdit"] = 19
279	left -= m["lastEdit"]
280
281	m["author"] = minInt(maxInt(left/3, 15), 10+left/8)
282	m["title"] = maxInt(left-m["author"], 10)
283
284	return m
285}
286
287func (bt *bugTable) render(v *gocui.View, maxX int) {
288	columnWidths := bt.getColumnWidths(maxX)
289
290	for _, excerpt := range bt.excerpts {
291		summaryTxt := fmt.Sprintf("%4d 💬", excerpt.LenComments)
292		if excerpt.LenComments <= 0 {
293			summaryTxt = ""
294		}
295		if excerpt.LenComments > 9999 {
296			summaryTxt = "    ∞ 💬"
297		}
298
299		var labelsTxt strings.Builder
300		for _, l := range excerpt.Labels {
301			lc256 := l.Color().Term256()
302			labelsTxt.WriteString(lc256.Escape())
303			labelsTxt.WriteString(" ◼")
304			labelsTxt.WriteString(lc256.Unescape())
305		}
306
307		var authorDisplayName string
308		if excerpt.AuthorId != "" {
309			author, err := bt.repo.ResolveIdentityExcerpt(excerpt.AuthorId)
310			if err != nil {
311				panic(err)
312			}
313			authorDisplayName = author.DisplayName()
314		} else {
315			authorDisplayName = excerpt.LegacyAuthor.DisplayName()
316		}
317
318		lastEditTime := time.Unix(excerpt.EditUnixTime, 0)
319
320		id := text.LeftPadMaxLine(excerpt.Id.Human(), columnWidths["id"], 1)
321		status := text.LeftPadMaxLine(excerpt.Status.String(), columnWidths["status"], 1)
322		labels := text.TruncateMax(labelsTxt.String(), minInt(columnWidths["title"]-2, 10))
323		title := text.LeftPadMaxLine(excerpt.Title, columnWidths["title"]-text.Len(labels), 1)
324		author := text.LeftPadMaxLine(authorDisplayName, columnWidths["author"], 1)
325		comments := text.LeftPadMaxLine(summaryTxt, columnWidths["comments"], 1)
326		lastEdit := text.LeftPadMaxLine(humanize.Time(lastEditTime), columnWidths["lastEdit"], 1)
327
328		_, _ = fmt.Fprintf(v, "%s %s %s%s %s %s %s\n",
329			colors.Cyan(id),
330			colors.Yellow(status),
331			title,
332			labels,
333			colors.Magenta(author),
334			comments,
335			lastEdit,
336		)
337	}
338
339	_ = v.SetHighlight(bt.selectCursor, true)
340}
341
342func (bt *bugTable) renderHeader(v *gocui.View, maxX int) {
343	columnWidths := bt.getColumnWidths(maxX)
344
345	id := text.LeftPadMaxLine("ID", columnWidths["id"], 1)
346	status := text.LeftPadMaxLine("STATUS", columnWidths["status"], 1)
347	title := text.LeftPadMaxLine("TITLE", columnWidths["title"], 1)
348	author := text.LeftPadMaxLine("AUTHOR", columnWidths["author"], 1)
349	comments := text.LeftPadMaxLine("COMMENTS", columnWidths["comments"], 1)
350	lastEdit := text.LeftPadMaxLine("LAST EDIT", columnWidths["lastEdit"], 1)
351
352	_, _ = fmt.Fprintf(v, "\n")
353	_, _ = fmt.Fprintf(v, "%s %s %s %s %s %s\n", id, status, title, author, comments, lastEdit)
354}
355
356func (bt *bugTable) renderFooter(v *gocui.View, maxX int) {
357	_, _ = fmt.Fprintf(v, " \nShowing %d of %d bugs", len(bt.excerpts), len(bt.allIds))
358}
359
360func (bt *bugTable) cursorDown(g *gocui.Gui, v *gocui.View) error {
361	// If we are at the bottom of the page, switch to the next one.
362	if bt.selectCursor+1 > bt.getTableLength()-1 {
363		_, max := v.Size()
364
365		if bt.pageCursor+max >= len(bt.allIds) {
366			return nil
367		}
368
369		bt.pageCursor += max
370		bt.selectCursor = 0
371
372		return bt.doPaginate(max)
373	}
374
375	bt.selectCursor = minInt(bt.selectCursor+1, bt.getTableLength()-1)
376
377	return nil
378}
379
380func (bt *bugTable) cursorUp(g *gocui.Gui, v *gocui.View) error {
381	// If we are at the top of the page, switch to the previous one.
382	if bt.selectCursor-1 < 0 {
383		_, max := v.Size()
384
385		if bt.pageCursor == 0 {
386			return nil
387		}
388
389		bt.pageCursor = maxInt(0, bt.pageCursor-max)
390		bt.selectCursor = max - 1
391
392		return bt.doPaginate(max)
393	}
394
395	bt.selectCursor = maxInt(bt.selectCursor-1, 0)
396
397	return nil
398}
399
400func (bt *bugTable) cursorClamp(v *gocui.View) error {
401	y := bt.selectCursor
402
403	y = minInt(y, bt.getTableLength()-1)
404	y = maxInt(y, 0)
405
406	bt.selectCursor = y
407
408	return nil
409}
410
411func (bt *bugTable) nextPage(g *gocui.Gui, v *gocui.View) error {
412	_, max := v.Size()
413
414	if bt.pageCursor+max >= len(bt.allIds) {
415		return nil
416	}
417
418	bt.pageCursor += max
419
420	return bt.doPaginate(max)
421}
422
423func (bt *bugTable) previousPage(g *gocui.Gui, v *gocui.View) error {
424	_, max := v.Size()
425
426	if bt.pageCursor == 0 {
427		return nil
428	}
429
430	bt.pageCursor = maxInt(0, bt.pageCursor-max)
431
432	return bt.doPaginate(max)
433}
434
435func (bt *bugTable) newBug(g *gocui.Gui, v *gocui.View) error {
436	return newBugWithEditor(bt.repo)
437}
438
439func (bt *bugTable) openBug(g *gocui.Gui, v *gocui.View) error {
440	id := bt.excerpts[bt.selectCursor].Id
441	b, err := bt.repo.ResolveBug(id)
442	if err != nil {
443		return err
444	}
445	ui.showBug.SetBug(b)
446	return ui.activateWindow(ui.showBug)
447}
448
449func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
450	ui.msgPopup.Activate("Pull from remote "+defaultRemote, "...")
451
452	go func() {
453		stdout, err := bt.repo.Fetch(defaultRemote)
454
455		if err != nil {
456			g.Update(func(gui *gocui.Gui) error {
457				ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
458				return nil
459			})
460		} else {
461			g.Update(func(gui *gocui.Gui) error {
462				ui.msgPopup.UpdateMessage(stdout)
463				return nil
464			})
465		}
466
467		var buffer bytes.Buffer
468		beginLine := ""
469
470		for result := range bt.repo.MergeAll(defaultRemote) {
471			if result.Status == entity.MergeStatusNothing {
472				continue
473			}
474
475			if result.Err != nil {
476				g.Update(func(gui *gocui.Gui) error {
477					ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
478					return nil
479				})
480			} else {
481				_, _ = fmt.Fprintf(&buffer, "%s%s: %s",
482					beginLine, colors.Cyan(result.Entity.Id().Human()), result,
483				)
484
485				beginLine = "\n"
486
487				g.Update(func(gui *gocui.Gui) error {
488					ui.msgPopup.UpdateMessage(buffer.String())
489					return nil
490				})
491			}
492		}
493
494		_, _ = fmt.Fprintf(&buffer, "%sdone", beginLine)
495
496		g.Update(func(gui *gocui.Gui) error {
497			ui.msgPopup.UpdateMessage(buffer.String())
498			return nil
499		})
500
501	}()
502
503	return nil
504}
505
506func (bt *bugTable) push(g *gocui.Gui, v *gocui.View) error {
507	ui.msgPopup.Activate("Push to remote "+defaultRemote, "...")
508
509	go func() {
510		// TODO: make the remote configurable
511		stdout, err := bt.repo.Push(defaultRemote)
512
513		if err != nil {
514			g.Update(func(gui *gocui.Gui) error {
515				ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
516				return nil
517			})
518		} else {
519			g.Update(func(gui *gocui.Gui) error {
520				ui.msgPopup.UpdateMessage(stdout)
521				return nil
522			})
523		}
524	}()
525
526	return nil
527}
528
529func (bt *bugTable) changeQuery(g *gocui.Gui, v *gocui.View) error {
530	return editQueryWithEditor(bt)
531}