show_bug.go

  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
 12	"github.com/MichaelMure/git-bug/cache"
 13	"github.com/MichaelMure/git-bug/entities/bug"
 14	"github.com/MichaelMure/git-bug/entities/common"
 15	"github.com/MichaelMure/git-bug/entity"
 16	"github.com/MichaelMure/git-bug/util/colors"
 17)
 18
 19const showBugView = "showBugView"
 20const showBugSidebarView = "showBugSidebarView"
 21const showBugInstructionView = "showBugInstructionView"
 22const showBugHeaderView = "showBugHeaderView"
 23
 24const timeLayout = "Jan 2 2006"
 25
 26var showBugHelp = helpBar{
 27	{"q", "Save and return"},
 28	{"←↓↑→,hjkl", "Navigation"},
 29	{"o", "Toggle open/close"},
 30	{"e", "Edit"},
 31	{"c", "Comment"},
 32	{"t", "Change title"},
 33}
 34
 35type showBug struct {
 36	cache              *cache.RepoCache
 37	bug                *cache.BugCache
 38	childViews         []string
 39	mainSelectableView []string
 40	sideSelectableView []string
 41	selected           string
 42	isOnSide           bool
 43	scroll             int
 44}
 45
 46func newShowBug(cache *cache.RepoCache) *showBug {
 47	return &showBug{
 48		cache: cache,
 49	}
 50}
 51
 52func (sb *showBug) SetBug(bug *cache.BugCache) {
 53	sb.bug = bug
 54	sb.scroll = 0
 55	sb.selected = ""
 56	sb.isOnSide = false
 57}
 58
 59func (sb *showBug) layout(g *gocui.Gui) error {
 60	maxX, maxY := g.Size()
 61	sb.childViews = nil
 62
 63	v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2, 0)
 64
 65	if err != nil {
 66		if !errors.Is(err, gocui.ErrUnknownView) {
 67			return err
 68		}
 69
 70		sb.childViews = append(sb.childViews, showBugView)
 71		v.Frame = false
 72	}
 73
 74	v.Clear()
 75	err = sb.renderMain(g, v)
 76	if err != nil {
 77		return err
 78	}
 79
 80	v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2, 0)
 81
 82	if err != nil {
 83		if !errors.Is(err, gocui.ErrUnknownView) {
 84			return err
 85		}
 86
 87		sb.childViews = append(sb.childViews, showBugSidebarView)
 88		v.Frame = false
 89	}
 90
 91	v.Clear()
 92	err = sb.renderSidebar(g, v)
 93	if err != nil {
 94		return err
 95	}
 96
 97	v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY, 0)
 98
 99	if err != nil {
100		if !errors.Is(err, gocui.ErrUnknownView) {
101			return err
102		}
103
104		sb.childViews = append(sb.childViews, showBugInstructionView)
105		v.Frame = false
106		v.FgColor = gocui.ColorWhite
107	}
108
109	v.Clear()
110	_, _ = fmt.Fprint(v, showBugHelp.Render(maxX))
111
112	_, err = g.SetViewOnTop(showBugInstructionView)
113	if err != nil {
114		return err
115	}
116
117	_, err = g.SetCurrentView(showBugView)
118	return err
119}
120
121func (sb *showBug) keybindings(g *gocui.Gui) error {
122	// Return
123	if err := g.SetKeybinding(showBugView, 'q', gocui.ModNone, sb.saveAndBack); err != nil {
124		return err
125	}
126
127	// Scrolling
128	if err := g.SetKeybinding(showBugView, gocui.KeyPgup, gocui.ModNone,
129		sb.scrollUp); err != nil {
130		return err
131	}
132	if err := g.SetKeybinding(showBugView, gocui.KeyPgdn, gocui.ModNone,
133		sb.scrollDown); err != nil {
134		return err
135	}
136
137	// Down
138	if err := g.SetKeybinding(showBugView, 'j', gocui.ModNone,
139		sb.selectNext); err != nil {
140		return err
141	}
142	if err := g.SetKeybinding(showBugView, gocui.KeyArrowDown, gocui.ModNone,
143		sb.selectNext); err != nil {
144		return err
145	}
146	// Up
147	if err := g.SetKeybinding(showBugView, 'k', gocui.ModNone,
148		sb.selectPrevious); err != nil {
149		return err
150	}
151	if err := g.SetKeybinding(showBugView, gocui.KeyArrowUp, gocui.ModNone,
152		sb.selectPrevious); err != nil {
153		return err
154	}
155
156	// Left
157	if err := g.SetKeybinding(showBugView, 'h', gocui.ModNone,
158		sb.left); err != nil {
159		return err
160	}
161	if err := g.SetKeybinding(showBugView, gocui.KeyArrowLeft, gocui.ModNone,
162		sb.left); err != nil {
163		return err
164	}
165	// Right
166	if err := g.SetKeybinding(showBugView, 'l', gocui.ModNone,
167		sb.right); err != nil {
168		return err
169	}
170	if err := g.SetKeybinding(showBugView, gocui.KeyArrowRight, gocui.ModNone,
171		sb.right); err != nil {
172		return err
173	}
174
175	// Comment
176	if err := g.SetKeybinding(showBugView, 'c', gocui.ModNone,
177		sb.comment); err != nil {
178		return err
179	}
180
181	// Open/close
182	if err := g.SetKeybinding(showBugView, 'o', gocui.ModNone,
183		sb.toggleOpenClose); err != nil {
184		return err
185	}
186
187	// Title
188	if err := g.SetKeybinding(showBugView, 't', gocui.ModNone,
189		sb.setTitle); err != nil {
190		return err
191	}
192
193	// Edit
194	if err := g.SetKeybinding(showBugView, 'e', gocui.ModNone,
195		sb.edit); err != nil {
196		return err
197	}
198
199	return nil
200}
201
202func (sb *showBug) disable(g *gocui.Gui) error {
203	for _, view := range sb.childViews {
204		if err := g.DeleteView(view); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
205			return err
206		}
207	}
208	return nil
209}
210
211func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
212	maxX, _ := mainView.Size()
213	x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
214
215	y0 -= sb.scroll
216
217	snap := sb.bug.Snapshot()
218
219	sb.mainSelectableView = nil
220
221	createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
222
223	edited := ""
224	if createTimelineItem.Edited() {
225		edited = " (edited)"
226	}
227
228	bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
229		colors.Cyan(snap.Id().Human()),
230		colors.Bold(snap.Title),
231		colors.Yellow(snap.Status),
232		colors.Magenta(snap.Author.DisplayName()),
233		snap.CreateTime.Format(timeLayout),
234		edited,
235	)
236	bugHeader, lines := text.Wrap(bugHeader, maxX, text.WrapIndent("   "))
237
238	v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
239	if err != nil {
240		return err
241	}
242
243	_, _ = fmt.Fprint(v, bugHeader)
244	y0 += lines + 1
245
246	for _, op := range snap.Timeline {
247		viewName := op.Id().String()
248
249		// TODO: me might skip the rendering of blocks that are outside of the view
250		// but to do that we need to rework how sb.mainSelectableView is maintained
251
252		switch op := op.(type) {
253
254		case *bug.CreateTimelineItem:
255			var content string
256			var lines int
257
258			if op.MessageIsEmpty() {
259				content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
260			} else {
261				content, lines = text.WrapLeftPadded(op.Message, maxX-1, 4)
262			}
263
264			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
265			if err != nil {
266				return err
267			}
268			_, _ = fmt.Fprint(v, content)
269			y0 += lines + 2
270
271		case *bug.AddCommentTimelineItem:
272			edited := ""
273			if op.Edited() {
274				edited = " (edited)"
275			}
276
277			var message string
278			if op.MessageIsEmpty() {
279				message, _ = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
280			} else {
281				message, _ = text.WrapLeftPadded(op.Message, maxX-1, 4)
282			}
283
284			content := fmt.Sprintf("%s commented on %s%s\n\n%s",
285				colors.Magenta(op.Author.DisplayName()),
286				op.CreatedAt.Time().Format(timeLayout),
287				edited,
288				message,
289			)
290			content, lines = text.Wrap(content, maxX)
291
292			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
293			if err != nil {
294				return err
295			}
296			_, _ = fmt.Fprint(v, content)
297			y0 += lines + 2
298
299		case *bug.SetTitleTimelineItem:
300			content := fmt.Sprintf("%s changed the title to %s on %s",
301				colors.Magenta(op.Author.DisplayName()),
302				colors.Bold(op.Title),
303				op.UnixTime.Time().Format(timeLayout),
304			)
305			content, lines := text.Wrap(content, maxX)
306
307			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
308			if err != nil {
309				return err
310			}
311			_, _ = fmt.Fprint(v, content)
312			y0 += lines + 2
313
314		case *bug.SetStatusTimelineItem:
315			content := fmt.Sprintf("%s %s the bug on %s",
316				colors.Magenta(op.Author.DisplayName()),
317				colors.Bold(op.Status.Action()),
318				op.UnixTime.Time().Format(timeLayout),
319			)
320			content, lines := text.Wrap(content, maxX)
321
322			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
323			if err != nil {
324				return err
325			}
326			_, _ = fmt.Fprint(v, content)
327			y0 += lines + 2
328
329		case *bug.LabelChangeTimelineItem:
330			var added []string
331			for _, label := range op.Added {
332				added = append(added, colors.Bold("\""+label+"\""))
333			}
334
335			var removed []string
336			for _, label := range op.Removed {
337				removed = append(removed, colors.Bold("\""+label+"\""))
338			}
339
340			var action bytes.Buffer
341
342			if len(added) > 0 {
343				action.WriteString("added ")
344				action.WriteString(strings.Join(added, ", "))
345
346				if len(removed) > 0 {
347					action.WriteString(" and ")
348				}
349			}
350
351			if len(removed) > 0 {
352				action.WriteString("removed ")
353				action.WriteString(strings.Join(removed, ", "))
354			}
355
356			if len(added)+len(removed) > 1 {
357				action.WriteString(" labels")
358			} else {
359				action.WriteString(" label")
360			}
361
362			content := fmt.Sprintf("%s %s on %s",
363				colors.Magenta(op.Author.DisplayName()),
364				action.String(),
365				op.UnixTime.Time().Format(timeLayout),
366			)
367			content, lines := text.Wrap(content, maxX)
368
369			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
370			if err != nil {
371				return err
372			}
373			_, _ = fmt.Fprint(v, content)
374			y0 += lines + 2
375		}
376	}
377
378	return nil
379}
380
381// emptyMessagePlaceholder return a formatted placeholder for an empty message
382func emptyMessagePlaceholder() string {
383	return colors.BlackBold(colors.WhiteBg("No description provided."))
384}
385
386func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
387	v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
388
389	if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
390		return nil, err
391	}
392
393	sb.childViews = append(sb.childViews, name)
394
395	if selectable {
396		sb.mainSelectableView = append(sb.mainSelectableView, name)
397	}
398
399	v.Frame = sb.selected == name
400
401	v.Clear()
402
403	return v, nil
404}
405
406func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
407	v, err := g.SetView(name, x0, y0, maxX, y0+height+1, 0)
408
409	if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
410		return nil, err
411	}
412
413	sb.childViews = append(sb.childViews, name)
414	sb.sideSelectableView = append(sb.sideSelectableView, name)
415
416	v.Frame = sb.selected == name
417
418	v.Clear()
419
420	return v, nil
421}
422
423func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
424	maxX, _ := sideView.Size()
425	x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
426	maxX += x0
427
428	snap := sb.bug.Snapshot()
429
430	sb.sideSelectableView = nil
431
432	labelStr := make([]string, len(snap.Labels))
433	for i, l := range snap.Labels {
434		lc := l.Color()
435		lc256 := lc.Term256()
436		labelStr[i] = lc256.Escape() + "◼ " + lc256.Unescape() + l.String()
437	}
438
439	labels := strings.Join(labelStr, "\n")
440	labels, lines := text.WrapLeftPadded(labels, maxX, 2)
441
442	content := fmt.Sprintf("%s\n\n%s", colors.Bold("  Labels"), labels)
443
444	v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
445	if err != nil {
446		return err
447	}
448
449	_, _ = fmt.Fprint(v, content)
450
451	return nil
452}
453
454func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
455	err := sb.bug.CommitAsNeeded()
456	if err != nil {
457		return err
458	}
459	err = ui.activateWindow(ui.bugTable)
460	if err != nil {
461		return err
462	}
463	return nil
464}
465
466func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
467	mainView, err := g.View(showBugView)
468	if err != nil {
469		return err
470	}
471
472	_, maxY := mainView.Size()
473
474	sb.scroll -= maxY / 2
475
476	sb.scroll = maxInt(sb.scroll, 0)
477
478	return nil
479}
480
481func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
482	_, maxY := v.Size()
483
484	lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
485
486	lastView, err := g.View(lastViewName)
487	if err != nil {
488		return err
489	}
490
491	_, vMaxY := lastView.Size()
492
493	_, vy0, _, _, err := g.ViewPosition(lastViewName)
494	if err != nil {
495		return err
496	}
497
498	maxScroll := vy0 + sb.scroll + vMaxY - maxY
499
500	sb.scroll += maxY / 2
501
502	sb.scroll = minInt(sb.scroll, maxScroll)
503
504	return nil
505}
506
507func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
508	var selectable []string
509	if sb.isOnSide {
510		selectable = sb.sideSelectableView
511	} else {
512		selectable = sb.mainSelectableView
513	}
514
515	for i, name := range selectable {
516		if name == sb.selected {
517			// special case to scroll up to the top
518			if i == 0 {
519				sb.scroll = 0
520			}
521
522			sb.selected = selectable[maxInt(i-1, 0)]
523			return sb.focusView(g)
524		}
525	}
526
527	if sb.selected == "" && len(selectable) > 0 {
528		sb.selected = selectable[0]
529	}
530
531	return sb.focusView(g)
532}
533
534func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
535	var selectable []string
536	if sb.isOnSide {
537		selectable = sb.sideSelectableView
538	} else {
539		selectable = sb.mainSelectableView
540	}
541
542	for i, name := range selectable {
543		if name == sb.selected {
544			sb.selected = selectable[minInt(i+1, len(selectable)-1)]
545			return sb.focusView(g)
546		}
547	}
548
549	if sb.selected == "" && len(selectable) > 0 {
550		sb.selected = selectable[0]
551	}
552
553	return sb.focusView(g)
554}
555
556func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
557	if sb.isOnSide {
558		sb.isOnSide = false
559		sb.selected = ""
560		return sb.selectNext(g, v)
561	}
562
563	if sb.selected == "" {
564		return sb.selectNext(g, v)
565	}
566
567	return nil
568}
569
570func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
571	if !sb.isOnSide {
572		sb.isOnSide = true
573		sb.selected = ""
574		return sb.selectNext(g, v)
575	}
576
577	if sb.selected == "" {
578		return sb.selectNext(g, v)
579	}
580
581	return nil
582}
583
584func (sb *showBug) focusView(g *gocui.Gui) error {
585	mainView, err := g.View(showBugView)
586	if err != nil {
587		return err
588	}
589
590	_, maxY := mainView.Size()
591
592	_, vy0, _, _, err := g.ViewPosition(sb.selected)
593	if err != nil {
594		return err
595	}
596
597	v, err := g.View(sb.selected)
598	if err != nil {
599		return err
600	}
601
602	_, vMaxY := v.Size()
603
604	vy1 := vy0 + vMaxY
605
606	if vy0 < 0 {
607		sb.scroll += vy0
608		return nil
609	}
610
611	if vy1 > maxY {
612		sb.scroll -= maxY - vy1
613	}
614
615	return nil
616}
617
618func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
619	return addCommentWithEditor(sb.bug)
620}
621
622func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
623	return setTitleWithEditor(sb.bug)
624}
625
626func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
627	switch sb.bug.Snapshot().Status {
628	case common.OpenStatus:
629		_, err := sb.bug.Close()
630		return err
631	case common.ClosedStatus:
632		_, err := sb.bug.Open()
633		return err
634	default:
635		return nil
636	}
637}
638
639func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
640	snap := sb.bug.Snapshot()
641
642	if sb.isOnSide {
643		return sb.editLabels(g, snap)
644	}
645
646	if sb.selected == "" {
647		return nil
648	}
649
650	op, err := snap.SearchTimelineItem(entity.Id(sb.selected))
651	if err != nil {
652		return err
653	}
654
655	switch op := op.(type) {
656	case *bug.AddCommentTimelineItem:
657		return editCommentWithEditor(sb.bug, op.Id(), op.Message)
658	case *bug.CreateTimelineItem:
659		return editCommentWithEditor(sb.bug, op.Id(), op.Message)
660	case *bug.LabelChangeTimelineItem:
661		return sb.editLabels(g, snap)
662	}
663
664	ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
665	return nil
666}
667
668func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
669	ui.labelSelect.SetBug(sb.cache, sb.bug)
670	return ui.activateWindow(ui.labelSelect)
671}