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