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