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 bugHeader := fmt.Sprintf("[%s] %s\n\n[%s] %s opened this bug on %s",
213 colors.Cyan(snap.HumanId()),
214 colors.Bold(snap.Title),
215 colors.Yellow(snap.Status),
216 colors.Magenta(snap.Author.Name),
217 snap.CreatedAt.Format(timeLayout),
218 )
219 bugHeader, lines := text.Wrap(bugHeader, maxX)
220
221 v, err := sb.createOpView(g, showBugHeaderView, x0, y0, maxX+1, lines, false)
222 if err != nil {
223 return err
224 }
225
226 fmt.Fprint(v, bugHeader)
227 y0 += lines + 1
228
229 for i, op := range snap.Operations {
230 viewName := fmt.Sprintf("op%d", i)
231
232 // TODO: me might skip the rendering of blocks that are outside of the view
233 // but to do that we need to rework how sb.mainSelectableView is maintained
234
235 switch op.(type) {
236
237 case bug.CreateOperation:
238 create := op.(bug.CreateOperation)
239 content, lines := text.WrapLeftPadded(create.Message, maxX, 4)
240
241 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
242 if err != nil {
243 return err
244 }
245 fmt.Fprint(v, content)
246 y0 += lines + 2
247
248 case bug.AddCommentOperation:
249 comment := op.(bug.AddCommentOperation)
250
251 message, _ := text.WrapLeftPadded(comment.Message, maxX, 4)
252 content := fmt.Sprintf("%s commented on %s\n\n%s",
253 colors.Magenta(comment.Author.Name),
254 comment.Time().Format(timeLayout),
255 message,
256 )
257 content, lines = text.Wrap(content, maxX)
258
259 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
260 if err != nil {
261 return err
262 }
263 fmt.Fprint(v, content)
264 y0 += lines + 2
265
266 case bug.SetTitleOperation:
267 setTitle := op.(bug.SetTitleOperation)
268
269 content := fmt.Sprintf("%s changed the title to %s on %s",
270 colors.Magenta(setTitle.Author.Name),
271 colors.Bold(setTitle.Title),
272 setTitle.Time().Format(timeLayout),
273 )
274 content, lines := text.Wrap(content, maxX)
275
276 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
277 if err != nil {
278 return err
279 }
280 fmt.Fprint(v, content)
281 y0 += lines + 2
282
283 case bug.SetStatusOperation:
284 setStatus := op.(bug.SetStatusOperation)
285
286 content := fmt.Sprintf("%s %s the bug on %s",
287 colors.Magenta(setStatus.Author.Name),
288 colors.Bold(setStatus.Status.Action()),
289 setStatus.Time().Format(timeLayout),
290 )
291 content, lines := text.Wrap(content, maxX)
292
293 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
294 if err != nil {
295 return err
296 }
297 fmt.Fprint(v, content)
298 y0 += lines + 2
299
300 case bug.LabelChangeOperation:
301 labelChange := op.(bug.LabelChangeOperation)
302
303 var added []string
304 for _, label := range labelChange.Added {
305 added = append(added, colors.Bold("\""+label+"\""))
306 }
307
308 var removed []string
309 for _, label := range labelChange.Removed {
310 removed = append(removed, colors.Bold("\""+label+"\""))
311 }
312
313 var action bytes.Buffer
314
315 if len(added) > 0 {
316 action.WriteString("added ")
317 action.WriteString(strings.Join(added, ", "))
318
319 if len(removed) > 0 {
320 action.WriteString(" and ")
321 }
322 }
323
324 if len(removed) > 0 {
325 action.WriteString("removed ")
326 action.WriteString(strings.Join(removed, ", "))
327 }
328
329 if len(added)+len(removed) > 1 {
330 action.WriteString(" labels")
331 } else {
332 action.WriteString(" label")
333 }
334
335 content := fmt.Sprintf("%s %s on %s",
336 colors.Magenta(labelChange.Author.Name),
337 action.String(),
338 labelChange.Time().Format(timeLayout),
339 )
340 content, lines := text.Wrap(content, maxX)
341
342 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
343 if err != nil {
344 return err
345 }
346 fmt.Fprint(v, content)
347 y0 += lines + 2
348 }
349 }
350
351 return nil
352}
353
354func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
355 v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
356
357 if err != nil && err != gocui.ErrUnknownView {
358 return nil, err
359 }
360
361 sb.childViews = append(sb.childViews, name)
362
363 if selectable {
364 sb.mainSelectableView = append(sb.mainSelectableView, name)
365 }
366
367 v.Frame = sb.selected == name
368
369 v.Clear()
370
371 return v, nil
372}
373
374func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
375 v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
376
377 if err != nil && err != gocui.ErrUnknownView {
378 return nil, err
379 }
380
381 sb.childViews = append(sb.childViews, name)
382 sb.sideSelectableView = append(sb.sideSelectableView, name)
383
384 v.Frame = sb.selected == name
385
386 v.Clear()
387
388 return v, nil
389}
390
391func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
392 maxX, _ := sideView.Size()
393 x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
394 maxX += x0
395
396 snap := sb.bug.Snapshot()
397
398 sb.sideSelectableView = nil
399
400 labelStr := make([]string, len(snap.Labels))
401 for i, l := range snap.Labels {
402 labelStr[i] = string(l)
403 }
404
405 labels := strings.Join(labelStr, "\n")
406 labels, lines := text.WrapLeftPadded(labels, maxX, 2)
407
408 content := fmt.Sprintf("%s\n\n%s", colors.Bold("Labels"), labels)
409
410 v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
411 if err != nil {
412 return err
413 }
414
415 fmt.Fprint(v, content)
416
417 return nil
418}
419
420func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
421 err := sb.bug.CommitAsNeeded()
422 if err != nil {
423 return err
424 }
425 err = ui.activateWindow(ui.bugTable)
426 if err != nil {
427 return err
428 }
429 return nil
430}
431
432func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
433 mainView, err := g.View(showBugView)
434 if err != nil {
435 return err
436 }
437
438 _, maxY := mainView.Size()
439
440 sb.scroll -= maxY / 2
441
442 sb.scroll = maxInt(sb.scroll, 0)
443
444 return nil
445}
446
447func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
448 _, maxY := v.Size()
449
450 lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
451
452 lastView, err := g.View(lastViewName)
453 if err != nil {
454 return err
455 }
456
457 _, vMaxY := lastView.Size()
458
459 _, vy0, _, _, err := g.ViewPosition(lastViewName)
460 if err != nil {
461 return err
462 }
463
464 maxScroll := vy0 + sb.scroll + vMaxY - maxY
465
466 sb.scroll += maxY / 2
467
468 sb.scroll = minInt(sb.scroll, maxScroll)
469
470 return nil
471}
472
473func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
474 var selectable []string
475 if sb.isOnSide {
476 selectable = sb.sideSelectableView
477 } else {
478 selectable = sb.mainSelectableView
479 }
480
481 for i, name := range selectable {
482 if name == sb.selected {
483 // special case to scroll up to the top
484 if i == 0 {
485 sb.scroll = 0
486 }
487
488 sb.selected = selectable[maxInt(i-1, 0)]
489 return sb.focusView(g)
490 }
491 }
492
493 if sb.selected == "" && len(selectable) > 0 {
494 sb.selected = selectable[0]
495 }
496
497 return sb.focusView(g)
498}
499
500func (sb *showBug) selectNext(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 sb.selected = selectable[minInt(i+1, len(selectable)-1)]
511 return sb.focusView(g)
512 }
513 }
514
515 if sb.selected == "" && len(selectable) > 0 {
516 sb.selected = selectable[0]
517 }
518
519 return sb.focusView(g)
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(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, 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}