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