bug_table.go

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