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/cache"
13 "github.com/MichaelMure/git-bug/entities/bug"
14 "github.com/MichaelMure/git-bug/entities/common"
15 "github.com/MichaelMure/git-bug/entity"
16 "github.com/MichaelMure/git-bug/util/colors"
17)
18
19const showBugView = "showBugView"
20const showBugSidebarView = "showBugSidebarView"
21const showBugInstructionView = "showBugInstructionView"
22const showBugHeaderView = "showBugHeaderView"
23
24const timeLayout = "Jan 2 2006"
25
26var showBugHelp = helpBar{
27 {"q", "Save and return"},
28 {"←↓↑→,hjkl", "Navigation"},
29 {"o", "Toggle open/close"},
30 {"e", "Edit"},
31 {"c", "Comment"},
32 {"t", "Change title"},
33}
34
35type showBug struct {
36 cache *cache.RepoCache
37 bug *cache.BugCache
38 childViews []string
39 mainSelectableView []string
40 sideSelectableView []string
41 selected string
42 isOnSide bool
43 scroll int
44}
45
46func newShowBug(cache *cache.RepoCache) *showBug {
47 return &showBug{
48 cache: cache,
49 }
50}
51
52func (sb *showBug) SetBug(bug *cache.BugCache) {
53 sb.bug = bug
54 sb.scroll = 0
55 sb.selected = ""
56 sb.isOnSide = false
57}
58
59func (sb *showBug) layout(g *gocui.Gui) error {
60 maxX, maxY := g.Size()
61 sb.childViews = nil
62
63 v, err := g.SetView(showBugView, 0, 0, maxX*2/3, maxY-2, 0)
64
65 if err != nil {
66 if !errors.Is(err, gocui.ErrUnknownView) {
67 return err
68 }
69
70 sb.childViews = append(sb.childViews, showBugView)
71 v.Frame = false
72 }
73
74 v.Clear()
75 err = sb.renderMain(g, v)
76 if err != nil {
77 return err
78 }
79
80 v, err = g.SetView(showBugSidebarView, maxX*2/3+1, 0, maxX-1, maxY-2, 0)
81
82 if err != nil {
83 if !errors.Is(err, gocui.ErrUnknownView) {
84 return err
85 }
86
87 sb.childViews = append(sb.childViews, showBugSidebarView)
88 v.Frame = false
89 }
90
91 v.Clear()
92 err = sb.renderSidebar(g, v)
93 if err != nil {
94 return err
95 }
96
97 v, err = g.SetView(showBugInstructionView, -1, maxY-2, maxX, maxY, 0)
98
99 if err != nil {
100 if !errors.Is(err, gocui.ErrUnknownView) {
101 return err
102 }
103
104 sb.childViews = append(sb.childViews, showBugInstructionView)
105 v.Frame = false
106 v.FgColor = gocui.ColorWhite
107 }
108
109 v.Clear()
110 _, _ = fmt.Fprint(v, showBugHelp.Render(maxX))
111
112 _, err = g.SetViewOnTop(showBugInstructionView)
113 if err != nil {
114 return err
115 }
116
117 _, err = g.SetCurrentView(showBugView)
118 return err
119}
120
121func (sb *showBug) keybindings(g *gocui.Gui) error {
122 // Return
123 if err := g.SetKeybinding(showBugView, 'q', gocui.ModNone, sb.saveAndBack); err != nil {
124 return err
125 }
126
127 // Scrolling
128 if err := g.SetKeybinding(showBugView, gocui.KeyPgup, gocui.ModNone,
129 sb.scrollUp); err != nil {
130 return err
131 }
132 if err := g.SetKeybinding(showBugView, gocui.KeyPgdn, gocui.ModNone,
133 sb.scrollDown); err != nil {
134 return err
135 }
136
137 // Down
138 if err := g.SetKeybinding(showBugView, 'j', gocui.ModNone,
139 sb.selectNext); err != nil {
140 return err
141 }
142 if err := g.SetKeybinding(showBugView, gocui.KeyArrowDown, gocui.ModNone,
143 sb.selectNext); err != nil {
144 return err
145 }
146 // Up
147 if err := g.SetKeybinding(showBugView, 'k', gocui.ModNone,
148 sb.selectPrevious); err != nil {
149 return err
150 }
151 if err := g.SetKeybinding(showBugView, gocui.KeyArrowUp, gocui.ModNone,
152 sb.selectPrevious); err != nil {
153 return err
154 }
155
156 // Left
157 if err := g.SetKeybinding(showBugView, 'h', gocui.ModNone,
158 sb.left); err != nil {
159 return err
160 }
161 if err := g.SetKeybinding(showBugView, gocui.KeyArrowLeft, gocui.ModNone,
162 sb.left); err != nil {
163 return err
164 }
165 // Right
166 if err := g.SetKeybinding(showBugView, 'l', gocui.ModNone,
167 sb.right); err != nil {
168 return err
169 }
170 if err := g.SetKeybinding(showBugView, gocui.KeyArrowRight, gocui.ModNone,
171 sb.right); err != nil {
172 return err
173 }
174
175 // Comment
176 if err := g.SetKeybinding(showBugView, 'c', gocui.ModNone,
177 sb.comment); err != nil {
178 return err
179 }
180
181 // Open/close
182 if err := g.SetKeybinding(showBugView, 'o', gocui.ModNone,
183 sb.toggleOpenClose); err != nil {
184 return err
185 }
186
187 // Title
188 if err := g.SetKeybinding(showBugView, 't', gocui.ModNone,
189 sb.setTitle); err != nil {
190 return err
191 }
192
193 // Edit
194 if err := g.SetKeybinding(showBugView, 'e', gocui.ModNone,
195 sb.edit); err != nil {
196 return err
197 }
198
199 return nil
200}
201
202func (sb *showBug) disable(g *gocui.Gui) error {
203 for _, view := range sb.childViews {
204 if err := g.DeleteView(view); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
205 return err
206 }
207 }
208 return nil
209}
210
211func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
212 maxX, _ := mainView.Size()
213 x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
214
215 y0 -= sb.scroll
216
217 snap := sb.bug.Snapshot()
218
219 sb.mainSelectableView = nil
220
221 createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
222
223 edited := ""
224 if createTimelineItem.Edited() {
225 edited = " (edited)"
226 }
227
228 bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
229 colors.Cyan(snap.Id().Human()),
230 colors.Bold(snap.Title),
231 colors.Yellow(snap.Status),
232 colors.Magenta(snap.Author.DisplayName()),
233 snap.CreateTime.Format(timeLayout),
234 edited,
235 )
236 bugHeader, lines := text.Wrap(bugHeader, maxX, text.WrapIndent(" "))
237
238 v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
239 if err != nil {
240 return err
241 }
242
243 _, _ = fmt.Fprint(v, bugHeader)
244 y0 += lines + 1
245
246 for _, op := range snap.Timeline {
247 viewName := op.Id().String()
248
249 // TODO: me might skip the rendering of blocks that are outside of the view
250 // but to do that we need to rework how sb.mainSelectableView is maintained
251
252 switch op := op.(type) {
253
254 case *bug.CreateTimelineItem:
255 var content string
256 var lines int
257
258 if op.MessageIsEmpty() {
259 content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
260 } else {
261 content, lines = text.WrapLeftPadded(op.Message, maxX-1, 4)
262 }
263
264 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
265 if err != nil {
266 return err
267 }
268 _, _ = fmt.Fprint(v, content)
269 y0 += lines + 2
270
271 case *bug.AddCommentTimelineItem:
272 edited := ""
273 if op.Edited() {
274 edited = " (edited)"
275 }
276
277 var message string
278 if op.MessageIsEmpty() {
279 message, _ = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
280 } else {
281 message, _ = text.WrapLeftPadded(op.Message, maxX-1, 4)
282 }
283
284 content := fmt.Sprintf("%s commented on %s%s\n\n%s",
285 colors.Magenta(op.Author.DisplayName()),
286 op.CreatedAt.Time().Format(timeLayout),
287 edited,
288 message,
289 )
290 content, lines = text.Wrap(content, maxX)
291
292 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
293 if err != nil {
294 return err
295 }
296 _, _ = fmt.Fprint(v, content)
297 y0 += lines + 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.BlackBold(colors.WhiteBg("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 && !errors.Is(err, gocui.ErrUnknownView) {
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 && !errors.Is(err, gocui.ErrUnknownView) {
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 common.OpenStatus:
629 _, err := sb.bug.Close()
630 return err
631 case common.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 := op.(type) {
656 case *bug.AddCommentTimelineItem:
657 return editCommentWithEditor(sb.bug, op.Id(), op.Message)
658 case *bug.CreateTimelineItem:
659 return editCommentWithEditor(sb.bug, op.Id(), op.Message)
660 case *bug.LabelChangeTimelineItem:
661 return sb.editLabels(g, snap)
662 }
663
664 ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
665 return nil
666}
667
668func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
669 ui.labelSelect.SetBug(sb.cache, sb.bug)
670 return ui.activateWindow(ui.labelSelect)
671}