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/entity"
11 "github.com/MichaelMure/git-bug/util/colors"
12 "github.com/MichaelMure/git-bug/util/text"
13 "github.com/MichaelMure/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 [o] Toggle open/close [e] Edit [c] Comment [t] Change title")
99
100 _, err = g.SetViewOnTop(showBugInstructionView)
101 if err != nil {
102 return err
103 }
104
105 _, err = g.SetCurrentView(showBugView)
106 return err
107}
108
109func (sb *showBug) keybindings(g *gocui.Gui) error {
110 // Return
111 if err := g.SetKeybinding(showBugView, 'q', gocui.ModNone, sb.saveAndBack); err != nil {
112 return err
113 }
114
115 // Scrolling
116 if err := g.SetKeybinding(showBugView, gocui.KeyPgup, gocui.ModNone,
117 sb.scrollUp); err != nil {
118 return err
119 }
120 if err := g.SetKeybinding(showBugView, gocui.KeyPgdn, gocui.ModNone,
121 sb.scrollDown); err != nil {
122 return err
123 }
124
125 // Down
126 if err := g.SetKeybinding(showBugView, 'j', gocui.ModNone,
127 sb.selectNext); err != nil {
128 return err
129 }
130 if err := g.SetKeybinding(showBugView, gocui.KeyArrowDown, gocui.ModNone,
131 sb.selectNext); err != nil {
132 return err
133 }
134 // Up
135 if err := g.SetKeybinding(showBugView, 'k', gocui.ModNone,
136 sb.selectPrevious); err != nil {
137 return err
138 }
139 if err := g.SetKeybinding(showBugView, gocui.KeyArrowUp, gocui.ModNone,
140 sb.selectPrevious); err != nil {
141 return err
142 }
143
144 // Left
145 if err := g.SetKeybinding(showBugView, 'h', gocui.ModNone,
146 sb.left); err != nil {
147 return err
148 }
149 if err := g.SetKeybinding(showBugView, gocui.KeyArrowLeft, gocui.ModNone,
150 sb.left); err != nil {
151 return err
152 }
153 // Right
154 if err := g.SetKeybinding(showBugView, 'l', gocui.ModNone,
155 sb.right); err != nil {
156 return err
157 }
158 if err := g.SetKeybinding(showBugView, gocui.KeyArrowRight, gocui.ModNone,
159 sb.right); err != nil {
160 return err
161 }
162
163 // Comment
164 if err := g.SetKeybinding(showBugView, 'c', gocui.ModNone,
165 sb.comment); err != nil {
166 return err
167 }
168
169 // Open/close
170 if err := g.SetKeybinding(showBugView, 'o', gocui.ModNone,
171 sb.toggleOpenClose); err != nil {
172 return err
173 }
174
175 // Title
176 if err := g.SetKeybinding(showBugView, 't', gocui.ModNone,
177 sb.setTitle); err != nil {
178 return err
179 }
180
181 // Edit
182 if err := g.SetKeybinding(showBugView, 'e', gocui.ModNone,
183 sb.edit); err != nil {
184 return err
185 }
186
187 return nil
188}
189
190func (sb *showBug) disable(g *gocui.Gui) error {
191 for _, view := range sb.childViews {
192 if err := g.DeleteView(view); err != nil && err != gocui.ErrUnknownView {
193 return err
194 }
195 }
196 return nil
197}
198
199func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
200 maxX, _ := mainView.Size()
201 x0, y0, _, _, _ := g.ViewPosition(mainView.Name())
202
203 y0 -= sb.scroll
204
205 snap := sb.bug.Snapshot()
206
207 sb.mainSelectableView = nil
208
209 createTimelineItem := snap.Timeline[0].(*bug.CreateTimelineItem)
210
211 edited := ""
212 if createTimelineItem.Edited() {
213 edited = " (edited)"
214 }
215
216 bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s%s",
217 colors.Cyan(snap.Id().Human()),
218 colors.Bold(snap.Title),
219 colors.Yellow(snap.Status),
220 colors.Magenta(snap.Author.DisplayName()),
221 snap.CreatedAt.Format(timeLayout),
222 edited,
223 )
224 bugHeader, lines := text.Wrap(bugHeader, maxX)
225
226 v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
227 if err != nil {
228 return err
229 }
230
231 _, _ = fmt.Fprint(v, bugHeader)
232 y0 += lines + 1
233
234 for _, op := range snap.Timeline {
235 viewName := op.Id().String()
236
237 // TODO: me might skip the rendering of blocks that are outside of the view
238 // but to do that we need to rework how sb.mainSelectableView is maintained
239
240 switch op.(type) {
241
242 case *bug.CreateTimelineItem:
243 create := op.(*bug.CreateTimelineItem)
244
245 var content string
246 var lines int
247
248 if create.MessageIsEmpty() {
249 content, lines = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
250 } else {
251 content, lines = text.WrapLeftPadded(create.Message, maxX-1, 4)
252 }
253
254 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
255 if err != nil {
256 return err
257 }
258 _, _ = fmt.Fprint(v, content)
259 y0 += lines + 2
260
261 case *bug.AddCommentTimelineItem:
262 comment := op.(*bug.AddCommentTimelineItem)
263
264 edited := ""
265 if comment.Edited() {
266 edited = " (edited)"
267 }
268
269 var message string
270 if comment.MessageIsEmpty() {
271 message, _ = text.WrapLeftPadded(emptyMessagePlaceholder(), maxX-1, 4)
272 } else {
273 message, _ = text.WrapLeftPadded(comment.Message, maxX-1, 4)
274 }
275
276 content := fmt.Sprintf("%s commented on %s%s\n\n%s",
277 colors.Magenta(comment.Author.DisplayName()),
278 comment.CreatedAt.Time().Format(timeLayout),
279 edited,
280 message,
281 )
282 content, lines = text.Wrap(content, maxX)
283
284 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
285 if err != nil {
286 return err
287 }
288 _, _ = fmt.Fprint(v, content)
289 y0 += lines + 2
290
291 case *bug.SetTitleTimelineItem:
292 setTitle := op.(*bug.SetTitleTimelineItem)
293
294 content := fmt.Sprintf("%s changed the title to %s on %s",
295 colors.Magenta(setTitle.Author.DisplayName()),
296 colors.Bold(setTitle.Title),
297 setTitle.UnixTime.Time().Format(timeLayout),
298 )
299 content, lines := text.Wrap(content, maxX)
300
301 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
302 if err != nil {
303 return err
304 }
305 _, _ = fmt.Fprint(v, content)
306 y0 += lines + 2
307
308 case *bug.SetStatusTimelineItem:
309 setStatus := op.(*bug.SetStatusTimelineItem)
310
311 content := fmt.Sprintf("%s %s the bug on %s",
312 colors.Magenta(setStatus.Author.DisplayName()),
313 colors.Bold(setStatus.Status.Action()),
314 setStatus.UnixTime.Time().Format(timeLayout),
315 )
316 content, lines := text.Wrap(content, maxX)
317
318 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
319 if err != nil {
320 return err
321 }
322 _, _ = fmt.Fprint(v, content)
323 y0 += lines + 2
324
325 case *bug.LabelChangeTimelineItem:
326 labelChange := op.(*bug.LabelChangeTimelineItem)
327
328 var added []string
329 for _, label := range labelChange.Added {
330 added = append(added, colors.Bold("\""+label+"\""))
331 }
332
333 var removed []string
334 for _, label := range labelChange.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(labelChange.Author.DisplayName()),
362 action.String(),
363 labelChange.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)
386
387 if err != nil && err != gocui.ErrUnknownView {
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)
406
407 if err != nil && err != gocui.ErrUnknownView {
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 color256 := l.Term256()
433 labelStr[i] = fmt.Sprintf("\x1b[38;5;%dm◼\x1b[0m %s", color256, string(l))
434 }
435
436 labels := strings.Join(labelStr, "\n")
437 labels, lines := text.WrapLeftPadded(labels, maxX, 2)
438
439 content := fmt.Sprintf("%s\n\n%s", colors.Bold(" Labels"), labels)
440
441 v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
442 if err != nil {
443 return err
444 }
445
446 _, _ = fmt.Fprint(v, content)
447
448 return nil
449}
450
451func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
452 err := sb.bug.CommitAsNeeded()
453 if err != nil {
454 return err
455 }
456 err = ui.activateWindow(ui.bugTable)
457 if err != nil {
458 return err
459 }
460 return nil
461}
462
463func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
464 mainView, err := g.View(showBugView)
465 if err != nil {
466 return err
467 }
468
469 _, maxY := mainView.Size()
470
471 sb.scroll -= maxY / 2
472
473 sb.scroll = maxInt(sb.scroll, 0)
474
475 return nil
476}
477
478func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
479 _, maxY := v.Size()
480
481 lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
482
483 lastView, err := g.View(lastViewName)
484 if err != nil {
485 return err
486 }
487
488 _, vMaxY := lastView.Size()
489
490 _, vy0, _, _, err := g.ViewPosition(lastViewName)
491 if err != nil {
492 return err
493 }
494
495 maxScroll := vy0 + sb.scroll + vMaxY - maxY
496
497 sb.scroll += maxY / 2
498
499 sb.scroll = minInt(sb.scroll, maxScroll)
500
501 return nil
502}
503
504func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
505 var selectable []string
506 if sb.isOnSide {
507 selectable = sb.sideSelectableView
508 } else {
509 selectable = sb.mainSelectableView
510 }
511
512 for i, name := range selectable {
513 if name == sb.selected {
514 // special case to scroll up to the top
515 if i == 0 {
516 sb.scroll = 0
517 }
518
519 sb.selected = selectable[maxInt(i-1, 0)]
520 return sb.focusView(g)
521 }
522 }
523
524 if sb.selected == "" && len(selectable) > 0 {
525 sb.selected = selectable[0]
526 }
527
528 return sb.focusView(g)
529}
530
531func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
532 var selectable []string
533 if sb.isOnSide {
534 selectable = sb.sideSelectableView
535 } else {
536 selectable = sb.mainSelectableView
537 }
538
539 for i, name := range selectable {
540 if name == sb.selected {
541 sb.selected = selectable[minInt(i+1, len(selectable)-1)]
542 return sb.focusView(g)
543 }
544 }
545
546 if sb.selected == "" && len(selectable) > 0 {
547 sb.selected = selectable[0]
548 }
549
550 return sb.focusView(g)
551}
552
553func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
554 if sb.isOnSide {
555 sb.isOnSide = false
556 sb.selected = ""
557 return sb.selectNext(g, v)
558 }
559
560 if sb.selected == "" {
561 return sb.selectNext(g, v)
562 }
563
564 return nil
565}
566
567func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
568 if !sb.isOnSide {
569 sb.isOnSide = true
570 sb.selected = ""
571 return sb.selectNext(g, v)
572 }
573
574 if sb.selected == "" {
575 return sb.selectNext(g, v)
576 }
577
578 return nil
579}
580
581func (sb *showBug) focusView(g *gocui.Gui) error {
582 mainView, err := g.View(showBugView)
583 if err != nil {
584 return err
585 }
586
587 _, maxY := mainView.Size()
588
589 _, vy0, _, _, err := g.ViewPosition(sb.selected)
590 if err != nil {
591 return err
592 }
593
594 v, err := g.View(sb.selected)
595 if err != nil {
596 return err
597 }
598
599 _, vMaxY := v.Size()
600
601 vy1 := vy0 + vMaxY
602
603 if vy0 < 0 {
604 sb.scroll += vy0
605 return nil
606 }
607
608 if vy1 > maxY {
609 sb.scroll -= maxY - vy1
610 }
611
612 return nil
613}
614
615func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
616 return addCommentWithEditor(sb.bug)
617}
618
619func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
620 return setTitleWithEditor(sb.bug)
621}
622
623func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
624 switch sb.bug.Snapshot().Status {
625 case bug.OpenStatus:
626 _, err := sb.bug.Close()
627 return err
628 case bug.ClosedStatus:
629 _, err := sb.bug.Open()
630 return err
631 default:
632 return nil
633 }
634}
635
636func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
637 snap := sb.bug.Snapshot()
638
639 if sb.isOnSide {
640 return sb.editLabels(g, snap)
641 }
642
643 if sb.selected == "" {
644 return nil
645 }
646
647 op, err := snap.SearchTimelineItem(entity.Id(sb.selected))
648 if err != nil {
649 return err
650 }
651
652 switch op.(type) {
653 case *bug.AddCommentTimelineItem:
654 message := op.(*bug.AddCommentTimelineItem).Message
655 return editCommentWithEditor(sb.bug, op.Id(), message)
656 case *bug.CreateTimelineItem:
657 preMessage := op.(*bug.CreateTimelineItem).Message
658 return editCommentWithEditor(sb.bug, op.Id(), preMessage)
659 case *bug.LabelChangeTimelineItem:
660 return sb.editLabels(g, snap)
661 }
662
663 ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
664 return nil
665}
666
667func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
668 ui.labelSelect.SetBug(sb.cache, sb.bug)
669 return ui.activateWindow(ui.labelSelect)
670}