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