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