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