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