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