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.RepoCacher
23 bug cache.BugCacher
24 childViews []string
25 mainSelectableView []string
26 sideSelectableView []string
27 selected string
28 isOnSide bool
29 scroll int
30}
31
32func newShowBug(cache cache.RepoCacher) *showBug {
33 return &showBug{
34 cache: cache,
35 }
36}
37
38func (sb *showBug) SetBug(bug cache.BugCacher) {
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, "[q] Save and return [←,h] Left [↓,j] Down [↑,k] Up [→,l] Right ")
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, 'q', 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 {
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.childViews[len(sb.childViews)-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 }
527
528 return sb.selectNext(g, v)
529}
530
531func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
532 if !sb.isOnSide {
533 sb.isOnSide = true
534 sb.selected = ""
535 }
536
537 return sb.selectNext(g, v)
538}
539
540func (sb *showBug) focusView(g *gocui.Gui) error {
541 mainView, err := g.View(showBugView)
542 if err != nil {
543 return err
544 }
545
546 _, maxY := mainView.Size()
547
548 _, vy0, _, _, err := g.ViewPosition(sb.selected)
549 if err != nil {
550 return err
551 }
552
553 v, err := g.View(sb.selected)
554 if err != nil {
555 return err
556 }
557
558 _, vMaxY := v.Size()
559
560 vy1 := vy0 + vMaxY
561
562 if vy0 < 0 {
563 sb.scroll += vy0
564 return nil
565 }
566
567 if vy1 > maxY {
568 sb.scroll -= maxY - vy1
569 }
570
571 return nil
572}
573
574func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
575 return addCommentWithEditor(sb.bug)
576}
577
578func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
579 return setTitleWithEditor(sb.bug)
580}
581
582func (sb *showBug) addLabel(g *gocui.Gui, v *gocui.View) error {
583 c := ui.inputPopup.Activate("Add labels")
584
585 go func() {
586 input := <-c
587
588 labels := strings.FieldsFunc(input, func(r rune) bool {
589 return r == ' ' || r == ','
590 })
591
592 err := sb.bug.ChangeLabels(trimLabels(labels), nil)
593 if err != nil {
594 ui.errorPopup.Activate(err.Error())
595 }
596
597 g.Update(func(gui *gocui.Gui) error {
598 return nil
599 })
600 }()
601
602 return nil
603}
604
605func (sb *showBug) removeLabel(g *gocui.Gui, v *gocui.View) error {
606 c := ui.inputPopup.Activate("Remove labels")
607
608 go func() {
609 input := <-c
610
611 labels := strings.FieldsFunc(input, func(r rune) bool {
612 return r == ' ' || r == ','
613 })
614
615 err := sb.bug.ChangeLabels(nil, trimLabels(labels))
616 if err != nil {
617 ui.errorPopup.Activate(err.Error())
618 }
619
620 g.Update(func(gui *gocui.Gui) error {
621 return nil
622 })
623 }()
624
625 return nil
626}
627
628func trimLabels(labels []string) []string {
629 var result []string
630
631 for _, label := range labels {
632 trimmed := strings.TrimSpace(label)
633 if len(trimmed) > 0 {
634 result = append(result, trimmed)
635 }
636 }
637 return result
638}