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 [e] Edit [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	// Edit
188	if err := g.SetKeybinding(showBugView, 'e', gocui.ModNone,
189		sb.edit); err != nil {
190		return err
191	}
192
193	// Labels
194	if err := g.SetKeybinding(showBugView, 'a', gocui.ModNone,
195		sb.addLabel); err != nil {
196		return err
197	}
198	if err := g.SetKeybinding(showBugView, 'r', gocui.ModNone,
199		sb.removeLabel); err != nil {
200		return err
201	}
202
203	return nil
204}
205
206func (sb *showBug) disable(g *gocui.Gui) error {
207	for _, view := range sb.childViews {
208		if err := g.DeleteView(view); err != nil && err != gocui.ErrUnknownView {
209			return err
210		}
211	}
212	return nil
213}
214
215func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
216	maxX, _ := mainView.Size()
217	x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
218
219	y0 -= sb.scroll
220
221	snap := sb.bug.Snapshot()
222
223	sb.mainSelectableView = nil
224
225	createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
226
227	edited := ""
228	if createTimelineItem.Edited() {
229		edited = " (edited)"
230	}
231
232	bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
233		colors.Cyan(snap.HumanId()),
234		colors.Bold(snap.Title),
235		colors.Yellow(snap.Status),
236		colors.Magenta(snap.Author.Name),
237		snap.CreatedAt.Format(timeLayout),
238		edited,
239	)
240	bugHeader, lines := text.Wrap(bugHeader, maxX)
241
242	v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
243	if err != nil {
244		return err
245	}
246
247	fmt.Fprint(v, bugHeader)
248	y0 += lines + 1
249
250	for _, op := range snap.Timeline {
251		viewName := op.Hash().String()
252
253		// TODO: me might skip the rendering of blocks that are outside of the view
254		// but to do that we need to rework how sb.mainSelectableView is maintained
255
256		switch op.(type) {
257
258		case *bug.CreateTimelineItem:
259			create := op.(*bug.CreateTimelineItem)
260			content, lines := text.WrapLeftPadded(create.Message, maxX, 4)
261
262			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
263			if err != nil {
264				return err
265			}
266			fmt.Fprint(v, content)
267			y0 += lines + 2
268
269		case *bug.AddCommentTimelineItem:
270			comment := op.(*bug.AddCommentTimelineItem)
271
272			edited := ""
273			if comment.Edited() {
274				edited = " (edited)"
275			}
276
277			message, _ := text.WrapLeftPadded(comment.Message, maxX, 4)
278			content := fmt.Sprintf("%s commented on %s%s\n\n%s",
279				colors.Magenta(comment.Author.Name),
280				comment.CreatedAt.Time().Format(timeLayout),
281				edited,
282				message,
283			)
284			content, lines = text.Wrap(content, maxX)
285
286			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
287			if err != nil {
288				return err
289			}
290			fmt.Fprint(v, content)
291			y0 += lines + 2
292
293		case *bug.SetTitleTimelineItem:
294			setTitle := op.(*bug.SetTitleTimelineItem)
295
296			content := fmt.Sprintf("%s changed the title to %s on %s",
297				colors.Magenta(setTitle.Author.Name),
298				colors.Bold(setTitle.Title),
299				setTitle.UnixTime.Time().Format(timeLayout),
300			)
301			content, lines := text.Wrap(content, maxX)
302
303			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
304			if err != nil {
305				return err
306			}
307			fmt.Fprint(v, content)
308			y0 += lines + 2
309
310		case *bug.SetStatusTimelineItem:
311			setStatus := op.(*bug.SetStatusTimelineItem)
312
313			content := fmt.Sprintf("%s %s the bug on %s",
314				colors.Magenta(setStatus.Author.Name),
315				colors.Bold(setStatus.Status.Action()),
316				setStatus.UnixTime.Time().Format(timeLayout),
317			)
318			content, lines := text.Wrap(content, maxX)
319
320			v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
321			if err != nil {
322				return err
323			}
324			fmt.Fprint(v, content)
325			y0 += lines + 2
326
327		case *bug.LabelChangeTimelineItem:
328			labelChange := op.(*bug.LabelChangeTimelineItem)
329
330			var added []string
331			for _, label := range labelChange.Added {
332				added = append(added, colors.Bold("\""+label+"\""))
333			}
334
335			var removed []string
336			for _, label := range labelChange.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(labelChange.Author.Name),
364				action.String(),
365				labelChange.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
381func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
382	v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
383
384	if err != nil && err != gocui.ErrUnknownView {
385		return nil, err
386	}
387
388	sb.childViews = append(sb.childViews, name)
389
390	if selectable {
391		sb.mainSelectableView = append(sb.mainSelectableView, name)
392	}
393
394	v.Frame = sb.selected == name
395
396	v.Clear()
397
398	return v, nil
399}
400
401func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
402	v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
403
404	if err != nil && err != gocui.ErrUnknownView {
405		return nil, err
406	}
407
408	sb.childViews = append(sb.childViews, name)
409	sb.sideSelectableView = append(sb.sideSelectableView, name)
410
411	v.Frame = sb.selected == name
412
413	v.Clear()
414
415	return v, nil
416}
417
418func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
419	maxX, _ := sideView.Size()
420	x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
421	maxX += x0
422
423	snap := sb.bug.Snapshot()
424
425	sb.sideSelectableView = nil
426
427	labelStr := make([]string, len(snap.Labels))
428	for i, l := range snap.Labels {
429		labelStr[i] = string(l)
430	}
431
432	labels := strings.Join(labelStr, "\n")
433	labels, lines := text.WrapLeftPadded(labels, maxX, 2)
434
435	content := fmt.Sprintf("%s\n\n%s", colors.Bold("Labels"), labels)
436
437	v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
438	if err != nil {
439		return err
440	}
441
442	fmt.Fprint(v, content)
443
444	return nil
445}
446
447func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
448	err := sb.bug.CommitAsNeeded()
449	if err != nil {
450		return err
451	}
452	err = ui.activateWindow(ui.bugTable)
453	if err != nil {
454		return err
455	}
456	return nil
457}
458
459func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
460	mainView, err := g.View(showBugView)
461	if err != nil {
462		return err
463	}
464
465	_, maxY := mainView.Size()
466
467	sb.scroll -= maxY / 2
468
469	sb.scroll = maxInt(sb.scroll, 0)
470
471	return nil
472}
473
474func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
475	_, maxY := v.Size()
476
477	lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
478
479	lastView, err := g.View(lastViewName)
480	if err != nil {
481		return err
482	}
483
484	_, vMaxY := lastView.Size()
485
486	_, vy0, _, _, err := g.ViewPosition(lastViewName)
487	if err != nil {
488		return err
489	}
490
491	maxScroll := vy0 + sb.scroll + vMaxY - maxY
492
493	sb.scroll += maxY / 2
494
495	sb.scroll = minInt(sb.scroll, maxScroll)
496
497	return nil
498}
499
500func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
501	var selectable []string
502	if sb.isOnSide {
503		selectable = sb.sideSelectableView
504	} else {
505		selectable = sb.mainSelectableView
506	}
507
508	for i, name := range selectable {
509		if name == sb.selected {
510			// special case to scroll up to the top
511			if i == 0 {
512				sb.scroll = 0
513			}
514
515			sb.selected = selectable[maxInt(i-1, 0)]
516			return sb.focusView(g)
517		}
518	}
519
520	if sb.selected == "" && len(selectable) > 0 {
521		sb.selected = selectable[0]
522	}
523
524	return sb.focusView(g)
525}
526
527func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
528	var selectable []string
529	if sb.isOnSide {
530		selectable = sb.sideSelectableView
531	} else {
532		selectable = sb.mainSelectableView
533	}
534
535	for i, name := range selectable {
536		if name == sb.selected {
537			sb.selected = selectable[minInt(i+1, len(selectable)-1)]
538			return sb.focusView(g)
539		}
540	}
541
542	if sb.selected == "" && len(selectable) > 0 {
543		sb.selected = selectable[0]
544	}
545
546	return sb.focusView(g)
547}
548
549func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
550	if sb.isOnSide {
551		sb.isOnSide = false
552		sb.selected = ""
553		return sb.selectNext(g, v)
554	}
555
556	if sb.selected == "" {
557		return sb.selectNext(g, v)
558	}
559
560	return nil
561}
562
563func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
564	if !sb.isOnSide {
565		sb.isOnSide = true
566		sb.selected = ""
567		return sb.selectNext(g, v)
568	}
569
570	if sb.selected == "" {
571		return sb.selectNext(g, v)
572	}
573
574	return nil
575}
576
577func (sb *showBug) focusView(g *gocui.Gui) error {
578	mainView, err := g.View(showBugView)
579	if err != nil {
580		return err
581	}
582
583	_, maxY := mainView.Size()
584
585	_, vy0, _, _, err := g.ViewPosition(sb.selected)
586	if err != nil {
587		return err
588	}
589
590	v, err := g.View(sb.selected)
591	if err != nil {
592		return err
593	}
594
595	_, vMaxY := v.Size()
596
597	vy1 := vy0 + vMaxY
598
599	if vy0 < 0 {
600		sb.scroll += vy0
601		return nil
602	}
603
604	if vy1 > maxY {
605		sb.scroll -= maxY - vy1
606	}
607
608	return nil
609}
610
611func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
612	return addCommentWithEditor(sb.bug)
613}
614
615func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
616	return setTitleWithEditor(sb.bug)
617}
618
619func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
620	switch sb.bug.Snapshot().Status {
621	case bug.OpenStatus:
622		return sb.bug.Close()
623	case bug.ClosedStatus:
624		return sb.bug.Open()
625	default:
626		return nil
627	}
628}
629
630func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
631	if sb.isOnSide {
632		ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
633		return nil
634	}
635
636	snap := sb.bug.Snapshot()
637
638	op, err := snap.SearchTimelineItem(git.Hash(sb.selected))
639	if err != nil {
640		return err
641	}
642
643	switch op.(type) {
644	case *bug.AddCommentTimelineItem:
645		message := op.(*bug.AddCommentTimelineItem).Message
646		return editCommentWithEditor(sb.bug, op.Hash(), message)
647	case *bug.CreateTimelineItem:
648		preMessage := op.(*bug.CreateTimelineItem).Message
649		return editCommentWithEditor(sb.bug, op.Hash(), preMessage)
650	}
651
652	ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
653	return nil
654}
655
656func (sb *showBug) addLabel(g *gocui.Gui, v *gocui.View) error {
657	c := ui.inputPopup.Activate("Add labels")
658
659	go func() {
660		input := <-c
661
662		labels := strings.FieldsFunc(input, func(r rune) bool {
663			return r == ' ' || r == ','
664		})
665
666		_, err := sb.bug.ChangeLabels(trimLabels(labels), nil)
667		if err != nil {
668			ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
669		}
670
671		g.Update(func(gui *gocui.Gui) error {
672			return nil
673		})
674	}()
675
676	return nil
677}
678
679func (sb *showBug) removeLabel(g *gocui.Gui, v *gocui.View) error {
680	c := ui.inputPopup.Activate("Remove labels")
681
682	go func() {
683		input := <-c
684
685		labels := strings.FieldsFunc(input, func(r rune) bool {
686			return r == ' ' || r == ','
687		})
688
689		_, err := sb.bug.ChangeLabels(nil, trimLabels(labels))
690		if err != nil {
691			ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
692		}
693
694		g.Update(func(gui *gocui.Gui) error {
695			return nil
696		})
697	}()
698
699	return nil
700}
701
702func trimLabels(labels []string) []string {
703	var result []string
704
705	for _, label := range labels {
706		trimmed := strings.TrimSpace(label)
707		if len(trimmed) > 0 {
708			result = append(result, trimmed)
709		}
710	}
711	return result
712}