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