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