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