show_bug.go

  1package termui
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"strings"
  7
  8	"github.com/MichaelMure/git-bug/cache"
  9	"github.com/MichaelMure/git-bug/operations"
 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, "[Esc] 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, gocui.KeyEsc, 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 operations.CreateOperation:
238			create := op.(operations.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 operations.AddCommentOperation:
249			comment := op.(operations.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 operations.SetTitleOperation:
267			setTitle := op.(operations.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 operations.SetStatusOperation:
284			setStatus := op.(operations.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 operations.LabelChangeOperation:
301			labelChange := op.(operations.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	ui.activateWindow(ui.bugTable)
426	return nil
427}
428
429func (sb *showBug) scrollUp(g *gocui.Gui, v *gocui.View) error {
430	mainView, err := g.View(showBugView)
431	if err != nil {
432		return err
433	}
434
435	_, maxY := mainView.Size()
436
437	sb.scroll -= maxY / 2
438
439	sb.scroll = maxInt(sb.scroll, 0)
440
441	return nil
442}
443
444func (sb *showBug) scrollDown(g *gocui.Gui, v *gocui.View) error {
445	_, maxY := v.Size()
446
447	lastViewName := sb.mainSelectableView[len(sb.mainSelectableView)-1]
448
449	lastView, err := g.View(lastViewName)
450	if err != nil {
451		return err
452	}
453
454	_, vMaxY := lastView.Size()
455
456	_, vy0, _, _, err := g.ViewPosition(lastViewName)
457	if err != nil {
458		return err
459	}
460
461	maxScroll := vy0 + sb.scroll + vMaxY - maxY
462
463	sb.scroll += maxY / 2
464
465	sb.scroll = minInt(sb.scroll, maxScroll)
466
467	return nil
468}
469
470func (sb *showBug) selectPrevious(g *gocui.Gui, v *gocui.View) error {
471	defer sb.focusView(g)
472
473	var selectable []string
474	if sb.isOnSide {
475		selectable = sb.sideSelectableView
476	} else {
477		selectable = sb.mainSelectableView
478	}
479
480	for i, name := range selectable {
481		if name == sb.selected {
482			// special case to scroll up to the top
483			if i == 0 {
484				sb.scroll = 0
485			}
486
487			sb.selected = selectable[maxInt(i-1, 0)]
488			return nil
489		}
490	}
491
492	if sb.selected == "" && len(selectable) > 0 {
493		sb.selected = selectable[0]
494	}
495
496	return nil
497}
498
499func (sb *showBug) selectNext(g *gocui.Gui, v *gocui.View) error {
500	defer sb.focusView(g)
501
502	var selectable []string
503	if sb.isOnSide {
504		selectable = sb.sideSelectableView
505	} else {
506		selectable = sb.mainSelectableView
507	}
508
509	for i, name := range selectable {
510		if name == sb.selected {
511			sb.selected = selectable[minInt(i+1, len(selectable)-1)]
512			return nil
513		}
514	}
515
516	if sb.selected == "" && len(selectable) > 0 {
517		sb.selected = selectable[0]
518	}
519
520	return nil
521}
522
523func (sb *showBug) left(g *gocui.Gui, v *gocui.View) error {
524	if sb.isOnSide {
525		sb.isOnSide = false
526		sb.selected = ""
527		return sb.selectNext(g, v)
528	}
529
530	if sb.selected == "" {
531		return sb.selectNext(g, v)
532	}
533
534	return nil
535}
536
537func (sb *showBug) right(g *gocui.Gui, v *gocui.View) error {
538	if !sb.isOnSide {
539		sb.isOnSide = true
540		sb.selected = ""
541		return sb.selectNext(g, v)
542	}
543
544	if sb.selected == "" {
545		return sb.selectNext(g, v)
546	}
547
548	return nil
549}
550
551func (sb *showBug) focusView(g *gocui.Gui) error {
552	mainView, err := g.View(showBugView)
553	if err != nil {
554		return err
555	}
556
557	_, maxY := mainView.Size()
558
559	_, vy0, _, _, err := g.ViewPosition(sb.selected)
560	if err != nil {
561		return err
562	}
563
564	v, err := g.View(sb.selected)
565	if err != nil {
566		return err
567	}
568
569	_, vMaxY := v.Size()
570
571	vy1 := vy0 + vMaxY
572
573	if vy0 < 0 {
574		sb.scroll += vy0
575		return nil
576	}
577
578	if vy1 > maxY {
579		sb.scroll -= maxY - vy1
580	}
581
582	return nil
583}
584
585func (sb *showBug) comment(g *gocui.Gui, v *gocui.View) error {
586	return addCommentWithEditor(sb.bug)
587}
588
589func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
590	return setTitleWithEditor(sb.bug)
591}
592
593func (sb *showBug) addLabel(g *gocui.Gui, v *gocui.View) error {
594	c := ui.inputPopup.Activate("Add labels")
595
596	go func() {
597		input := <-c
598
599		labels := strings.FieldsFunc(input, func(r rune) bool {
600			return r == ' ' || r == ','
601		})
602
603		_, err := sb.bug.ChangeLabels(trimLabels(labels), nil)
604		if err != nil {
605			ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
606		}
607
608		g.Update(func(gui *gocui.Gui) error {
609			return nil
610		})
611	}()
612
613	return nil
614}
615
616func (sb *showBug) removeLabel(g *gocui.Gui, v *gocui.View) error {
617	c := ui.inputPopup.Activate("Remove labels")
618
619	go func() {
620		input := <-c
621
622		labels := strings.FieldsFunc(input, func(r rune) bool {
623			return r == ' ' || r == ','
624		})
625
626		_, err := sb.bug.ChangeLabels(nil, trimLabels(labels))
627		if err != nil {
628			ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
629		}
630
631		g.Update(func(gui *gocui.Gui) error {
632			return nil
633		})
634	}()
635
636	return nil
637}
638
639func trimLabels(labels []string) []string {
640	var result []string
641
642	for _, label := range labels {
643		trimmed := strings.TrimSpace(label)
644		if len(trimmed) > 0 {
645			result = append(result, trimmed)
646		}
647	}
648	return result
649}