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/MichaelMure/git-bug/util/git"
13 "github.com/jroimartin/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.HumanId()),
218 colors.Bold(snap.Title),
219 colors.Yellow(snap.Status),
220 colors.Magenta(snap.Author.Name),
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.Hash().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 content, lines := text.WrapLeftPadded(create.Message, maxX, 4)
245
246 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
247 if err != nil {
248 return err
249 }
250 fmt.Fprint(v, content)
251 y0 += lines + 2
252
253 case *bug.AddCommentTimelineItem:
254 comment := op.(*bug.AddCommentTimelineItem)
255
256 edited := ""
257 if comment.Edited() {
258 edited = " (edited)"
259 }
260
261 message, _ := text.WrapLeftPadded(comment.Message, maxX, 4)
262 content := fmt.Sprintf("%s commented on %s%s\n\n%s",
263 colors.Magenta(comment.Author.Name),
264 comment.CreatedAt.Time().Format(timeLayout),
265 edited,
266 message,
267 )
268 content, lines = text.Wrap(content, maxX)
269
270 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
271 if err != nil {
272 return err
273 }
274 fmt.Fprint(v, content)
275 y0 += lines + 2
276
277 case *bug.SetTitleTimelineItem:
278 setTitle := op.(*bug.SetTitleTimelineItem)
279
280 content := fmt.Sprintf("%s changed the title to %s on %s",
281 colors.Magenta(setTitle.Author.Name),
282 colors.Bold(setTitle.Title),
283 setTitle.UnixTime.Time().Format(timeLayout),
284 )
285 content, lines := text.Wrap(content, maxX)
286
287 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
288 if err != nil {
289 return err
290 }
291 fmt.Fprint(v, content)
292 y0 += lines + 2
293
294 case *bug.SetStatusTimelineItem:
295 setStatus := op.(*bug.SetStatusTimelineItem)
296
297 content := fmt.Sprintf("%s %s the bug on %s",
298 colors.Magenta(setStatus.Author.Name),
299 colors.Bold(setStatus.Status.Action()),
300 setStatus.UnixTime.Time().Format(timeLayout),
301 )
302 content, lines := text.Wrap(content, maxX)
303
304 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
305 if err != nil {
306 return err
307 }
308 fmt.Fprint(v, content)
309 y0 += lines + 2
310
311 case *bug.LabelChangeTimelineItem:
312 labelChange := op.(*bug.LabelChangeTimelineItem)
313
314 var added []string
315 for _, label := range labelChange.Added {
316 added = append(added, colors.Bold("\""+label+"\""))
317 }
318
319 var removed []string
320 for _, label := range labelChange.Removed {
321 removed = append(removed, colors.Bold("\""+label+"\""))
322 }
323
324 var action bytes.Buffer
325
326 if len(added) > 0 {
327 action.WriteString("added ")
328 action.WriteString(strings.Join(added, ", "))
329
330 if len(removed) > 0 {
331 action.WriteString(" and ")
332 }
333 }
334
335 if len(removed) > 0 {
336 action.WriteString("removed ")
337 action.WriteString(strings.Join(removed, ", "))
338 }
339
340 if len(added)+len(removed) > 1 {
341 action.WriteString(" labels")
342 } else {
343 action.WriteString(" label")
344 }
345
346 content := fmt.Sprintf("%s %s on %s",
347 colors.Magenta(labelChange.Author.Name),
348 action.String(),
349 labelChange.UnixTime.Time().Format(timeLayout),
350 )
351 content, lines := text.Wrap(content, maxX)
352
353 v, err := sb.createOpView(g, viewName, x0, y0, maxX+1, lines, true)
354 if err != nil {
355 return err
356 }
357 fmt.Fprint(v, content)
358 y0 += lines + 2
359 }
360 }
361
362 return nil
363}
364
365func (sb *showBug) createOpView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int, selectable bool) (*gocui.View, error) {
366 v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
367
368 if err != nil && err != gocui.ErrUnknownView {
369 return nil, err
370 }
371
372 sb.childViews = append(sb.childViews, name)
373
374 if selectable {
375 sb.mainSelectableView = append(sb.mainSelectableView, name)
376 }
377
378 v.Frame = sb.selected == name
379
380 v.Clear()
381
382 return v, nil
383}
384
385func (sb *showBug) createSideView(g *gocui.Gui, name string, x0 int, y0 int, maxX int, height int) (*gocui.View, error) {
386 v, err := g.SetView(name, x0, y0, maxX, y0+height+1)
387
388 if err != nil && err != gocui.ErrUnknownView {
389 return nil, err
390 }
391
392 sb.childViews = append(sb.childViews, name)
393 sb.sideSelectableView = append(sb.sideSelectableView, name)
394
395 v.Frame = sb.selected == name
396
397 v.Clear()
398
399 return v, nil
400}
401
402func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
403 maxX, _ := sideView.Size()
404 x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
405 maxX += x0
406
407 snap := sb.bug.Snapshot()
408
409 sb.sideSelectableView = nil
410
411 labelStr := make([]string, len(snap.Labels))
412 for i, l := range snap.Labels {
413 labelStr[i] = string(l)
414 }
415
416 labels := strings.Join(labelStr, "\n")
417 labels, lines := text.WrapLeftPadded(labels, maxX, 2)
418
419 content := fmt.Sprintf("%s\n\n%s", colors.Bold("Labels"), labels)
420
421 v, err := sb.createSideView(g, "sideLabels", x0, y0, maxX, lines+2)
422 if err != nil {
423 return err
424 }
425
426 fmt.Fprint(v, content)
427
428 return nil
429}
430
431func (sb *showBug) saveAndBack(g *gocui.Gui, v *gocui.View) error {
432 err := sb.bug.CommitAsNeeded()
433 if err != nil {
434 return err
435 }
436 err = ui.activateWindow(ui.bugTable)
437 if err != nil {
438 return err
439 }
440 return nil
441}
442
443func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
444 mainView, err := g.View(showBugView)
445 if err != nil {
446 return err
447 }
448
449 _, maxY := mainView.Size()
450
451 sb.scroll -= maxY / 2
452
453 sb.scroll = maxInt(sb.scroll, 0)
454
455 return nil
456}
457
458func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
459 _, maxY := v.Size()
460
461 lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
462
463 lastView, err := g.View(lastViewName)
464 if err != nil {
465 return err
466 }
467
468 _, vMaxY := lastView.Size()
469
470 _, vy0, _, _, err := g.ViewPosition(lastViewName)
471 if err != nil {
472 return err
473 }
474
475 maxScroll := vy0 + sb.scroll + vMaxY - maxY
476
477 sb.scroll += maxY / 2
478
479 sb.scroll = minInt(sb.scroll, maxScroll)
480
481 return nil
482}
483
484func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
485 var selectable []string
486 if sb.isOnSide {
487 selectable = sb.sideSelectableView
488 } else {
489 selectable = sb.mainSelectableView
490 }
491
492 for i, name := range selectable {
493 if name == sb.selected {
494 // special case to scroll up to the top
495 if i == 0 {
496 sb.scroll = 0
497 }
498
499 sb.selected = selectable[maxInt(i-1, 0)]
500 return sb.focusView(g)
501 }
502 }
503
504 if sb.selected == "" && len(selectable) > 0 {
505 sb.selected = selectable[0]
506 }
507
508 return sb.focusView(g)
509}
510
511func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
512 var selectable []string
513 if sb.isOnSide {
514 selectable = sb.sideSelectableView
515 } else {
516 selectable = sb.mainSelectableView
517 }
518
519 for i, name := range selectable {
520 if name == sb.selected {
521 sb.selected = selectable[minInt(i+1, len(selectable)-1)]
522 return sb.focusView(g)
523 }
524 }
525
526 if sb.selected == "" && len(selectable) > 0 {
527 sb.selected = selectable[0]
528 }
529
530 return sb.focusView(g)
531}
532
533func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
534 if sb.isOnSide {
535 sb.isOnSide = false
536 sb.selected = ""
537 return sb.selectNext(g, v)
538 }
539
540 if sb.selected == "" {
541 return sb.selectNext(g, v)
542 }
543
544 return nil
545}
546
547func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
548 if !sb.isOnSide {
549 sb.isOnSide = true
550 sb.selected = ""
551 return sb.selectNext(g, v)
552 }
553
554 if sb.selected == "" {
555 return sb.selectNext(g, v)
556 }
557
558 return nil
559}
560
561func (sb *showBug) focusView(g *gocui.Gui) error {
562 mainView, err := g.View(showBugView)
563 if err != nil {
564 return err
565 }
566
567 _, maxY := mainView.Size()
568
569 _, vy0, _, _, err := g.ViewPosition(sb.selected)
570 if err != nil {
571 return err
572 }
573
574 v, err := g.View(sb.selected)
575 if err != nil {
576 return err
577 }
578
579 _, vMaxY := v.Size()
580
581 vy1 := vy0 + vMaxY
582
583 if vy0 < 0 {
584 sb.scroll += vy0
585 return nil
586 }
587
588 if vy1 > maxY {
589 sb.scroll -= maxY - vy1
590 }
591
592 return nil
593}
594
595func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
596 return addCommentWithEditor(sb.bug)
597}
598
599func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
600 return setTitleWithEditor(sb.bug)
601}
602
603func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
604 switch sb.bug.Snapshot().Status {
605 case bug.OpenStatus:
606 return sb.bug.Close()
607 case bug.ClosedStatus:
608 return sb.bug.Open()
609 default:
610 return nil
611 }
612}
613
614func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
615 snap := sb.bug.Snapshot()
616
617 if sb.isOnSide {
618 return sb.editLabels(g, snap)
619 }
620
621 op, err := snap.SearchTimelineItem(git.Hash(sb.selected))
622 if err != nil {
623 return err
624 }
625
626 switch op.(type) {
627 case *bug.AddCommentTimelineItem:
628 message := op.(*bug.AddCommentTimelineItem).Message
629 return editCommentWithEditor(sb.bug, op.Hash(), message)
630 case *bug.CreateTimelineItem:
631 preMessage := op.(*bug.CreateTimelineItem).Message
632 return editCommentWithEditor(sb.bug, op.Hash(), preMessage)
633 case *bug.LabelChangeTimelineItem:
634 return sb.editLabels(g, snap)
635 }
636
637 ui.msgPopup.Activate(msgPopupErrorTitle, "Selected field is not editable.")
638 return nil
639}
640
641func (sb *showBug) editLabels(g *gocui.Gui, snap *bug.Snapshot) error {
642 ui.labelSelect.SetBug(sb.cache, sb.bug)
643 return ui.activateWindow(ui.labelSelect)
644}