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