show_bug.go

  1package termui
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"strings"
  7
  8	markdown "github.com/MichaelMure/go-term-markdown"
  9	"github.com/MichaelMure/go-term-text"
 10	"github.com/awesome-gocui/gocui"
 11
 12	"github.com/MichaelMure/git-bug/bug"
 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 showBugView = "showBugView"
 19const showBugSidebarView = "showBugSidebarView"
 20const showBugInstructionView = "showBugInstructionView"
 21const showBugHeaderView = "showBugHeaderView"
 22
 23const timeLayout = "Jan 2 2006"
 24
 25type showBug struct {
 26	cache              *cache.RepoCache
 27	bug                *cache.BugCache
 28	childViews         []string
 29	mainSelectableView []string
 30	sideSelectableView []string
 31	selected           string
 32	isOnSide           bool
 33	scroll             int
 34}
 35
 36func newShowBug(cache *cache.RepoCache) *showBug {
 37	return &showBug{
 38		cache: cache,
 39	}
 40}
 41
 42func (sb *showBug) SetBug(bug *cache.BugCache) {
 43	sb.bug = bug
 44	sb.scroll = 0
 45	sb.selected = ""
 46	sb.isOnSide = false
 47}
 48
 49func (sb *showBug) layout(g *gocui.Gui) error {
 50	maxX, maxY := g.Size()
 51	sb.childViews = nil
 52
 53	v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2, 0)
 54
 55	if err != nil {
 56		if !gocui.IsUnknownView(err) {
 57			return err
 58		}
 59
 60		sb.childViews = append(sb.childViews, showBugView)
 61		v.Frame = false
 62	}
 63
 64	v.Clear()
 65	err = sb.renderMain(g, v)
 66	if err != nil {
 67		return err
 68	}
 69
 70	v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2, 0)
 71
 72	if err != nil {
 73		if !gocui.IsUnknownView(err) {
 74			return err
 75		}
 76
 77		sb.childViews = append(sb.childViews, showBugSidebarView)
 78		v.Frame = false
 79	}
 80
 81	v.Clear()
 82	err = sb.renderSidebar(g, v)
 83	if err != nil {
 84		return err
 85	}
 86
 87	v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY, 0)
 88
 89	if err != nil {
 90		if !gocui.IsUnknownView(err) {
 91			return err
 92		}
 93
 94		sb.childViews = append(sb.childViews, showBugInstructionView)
 95		v.Frame = false
 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	mkdwnOpts := []markdown.Options{
203		markdown.WithImageDithering(markdown.DitheringWithBlocks),
204	}
205
206	maxX, _ := mainView.Size()
207	x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
208
209	y0 -= sb.scroll
210
211	snap := sb.bug.Snapshot()
212
213	sb.mainSelectableView = nil
214
215	createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
216
217	edited := ""
218	if createTimelineItem.Edited() {
219		edited = " (edited)"
220	}
221
222	bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
223		colors.Cyan(snap.Id().Human()),
224		colors.Bold(snap.Title),
225		colors.Yellow(snap.Status),
226		colors.Magenta(snap.Author.DisplayName()),
227		snap.CreatedAt.Format(timeLayout),
228		edited,
229	)
230	bugHeader, lines := text.Wrap(bugHeader, maxX)
231
232	v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
233	if err != nil {
234		return err
235	}
236
237	_, _ = fmt.Fprint(v, bugHeader)
238	y0 += lines + 1
239
240	for _, op := range snap.Timeline {
241		viewName := op.Id().String()
242
243		// TODO: me might skip the rendering of blocks that are outside of the view
244		// but to do that we need to rework how sb.mainSelectableView is maintained
245
246		switch op := op.(type) {
247
248		case *bug.CreateTimelineItem:
249			var content string
250			var lines int
251
252			if op.MessageIsEmpty() {
253				content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
254			} else {
255				raw := markdown.Render(op.Message, maxX-1, 4, mkdwnOpts...)
256				content = string(raw)
257				lines = strings.Count(content, "\n")
258			}
259
260			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
261			if err != nil {
262				return err
263			}
264			_, _ = fmt.Fprint(v, content)
265			y0 += lines + 2
266
267		case *bug.AddCommentTimelineItem:
268			edited := ""
269			if op.Edited() {
270				edited = " (edited)"
271			}
272
273			var content string
274			var lines int
275
276			if op.MessageIsEmpty() {
277				content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
278			} else {
279				raw := markdown.Render(op.Message, maxX-1, 4, mkdwnOpts...)
280				content = string(raw)
281				lines = strings.Count(content, "\n")
282			}
283
284			header := fmt.Sprintf("%s commented on %s%s\n\n",
285				colors.Magenta(op.Author.DisplayName()),
286				op.CreatedAt.Time().Format(timeLayout),
287				edited,
288			)
289			headerWrapped, headerLines := text.Wrap(header, maxX)
290
291			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines+headerLines, true)
292			if err != nil {
293				return err
294			}
295			_, _ = fmt.Fprint(v, headerWrapped)
296			_, _ = fmt.Fprint(v, content)
297			y0 += lines + headerLines + 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.GreyBold("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 && !gocui.IsUnknownView(err) {
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 && !gocui.IsUnknownView(err) {
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 bug.OpenStatus:
629		_, err := sb.bug.Close()
630		return err
631	case bug.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.(type) {
656	case *bug.AddCommentTimelineItem:
657		message := op.(*bug.AddCommentTimelineItem).Message
658		return editCommentWithEditor(sb.bug, op.Id(), message)
659	case *bug.CreateTimelineItem:
660		preMessage := op.(*bug.CreateTimelineItem).Message
661		return editCommentWithEditor(sb.bug, op.Id(), preMessage)
662	case *bug.LabelChangeTimelineItem:
663		return sb.editLabels(g, snap)
664	}
665
666	ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
667	return nil
668}
669
670func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
671	ui.labelSelect.SetBug(sb.cache, sb.bug)
672	return ui.activateWindow(ui.labelSelect)
673}