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