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}