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