show_bug.go

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