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