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.CreateTime.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}