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