show_bug.go

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