1package termui
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "strings"
8
9 text "github.com/MichaelMure/go-term-text"
10 "github.com/awesome-gocui/gocui"
11 "github.com/dustin/go-humanize"
12
13 "github.com/git-bug/git-bug/cache"
14 "github.com/git-bug/git-bug/entity"
15 "github.com/git-bug/git-bug/query"
16 "github.com/git-bug/git-bug/util/colors"
17)
18
19const bugTableView = "bugTableView"
20const bugTableHeaderView = "bugTableHeaderView"
21const bugTableFooterView = "bugTableFooterView"
22const bugTableInstructionView = "bugTableInstructionView"
23
24const defaultRemote = "origin"
25const defaultQuery = "status:open"
26
27var bugTableHelp = helpBar{
28 {"q", "Quit"},
29 {"s", "Search"},
30 {"←↓↑→,hjkl", "Navigation"},
31 {"↵", "Open bug"},
32 {"n", "New bug"},
33 {"i", "Pull"},
34 {"o", "Push"},
35}
36
37type bugTable struct {
38 repo *cache.RepoCache
39 queryStr string
40 query *query.Query
41 allIds []entity.Id
42 excerpts []*cache.BugExcerpt
43 pageCursor int
44 selectCursor int
45}
46
47func newBugTable(c *cache.RepoCache) *bugTable {
48 q, err := query.Parse(defaultQuery)
49 if err != nil {
50 panic(err)
51 }
52
53 return &bugTable{
54 repo: c,
55 query: q,
56 queryStr: defaultQuery,
57 pageCursor: 0,
58 selectCursor: 0,
59 }
60}
61
62func (bt *bugTable) layout(g *gocui.Gui) error {
63 maxX, maxY := g.Size()
64
65 if maxY < 4 {
66 // window too small !
67 return nil
68 }
69
70 v, err := g.SetView(bugTableHeaderView, -1, -1, maxX, 1, 0)
71
72 if err != nil {
73 if !errors.Is(err, gocui.ErrUnknownView) {
74 return err
75 }
76
77 v.Frame = false
78 }
79
80 v.Clear()
81 bt.renderHeader(v, maxX)
82
83 v, err = g.SetView(bugTableView, -1, 0, maxX, maxY-3, 0)
84
85 if err != nil {
86 if !errors.Is(err, gocui.ErrUnknownView) {
87 return err
88 }
89
90 v.Frame = false
91 v.SelBgColor = gocui.ColorWhite
92 v.SelFgColor = gocui.ColorBlack
93 }
94
95 viewWidth, viewHeight := v.Size()
96 err = bt.paginate(viewHeight)
97 if err != nil {
98 return err
99 }
100
101 err = bt.cursorClamp(v)
102 if err != nil {
103 return err
104 }
105
106 v.Clear()
107 bt.render(v, viewWidth)
108
109 v, err = g.SetView(bugTableFooterView, -1, maxY-4, maxX, maxY, 0)
110
111 if err != nil {
112 if !errors.Is(err, gocui.ErrUnknownView) {
113 return err
114 }
115
116 v.Frame = false
117 }
118
119 v.Clear()
120 bt.renderFooter(v, maxX)
121
122 v, err = g.SetView(bugTableInstructionView, -1, maxY-2, maxX, maxY, 0)
123
124 if err != nil {
125 if !errors.Is(err, gocui.ErrUnknownView) {
126 return err
127 }
128
129 v.Frame = false
130 v.FgColor = gocui.ColorWhite
131 }
132 v.Clear()
133 bt.renderHelp(v, maxX)
134
135 _, err = g.SetCurrentView(bugTableView)
136 return err
137}
138
139func (bt *bugTable) keybindings(g *gocui.Gui) error {
140 // Quit
141 if err := g.SetKeybinding(bugTableView, 'q', gocui.ModNone, quit); err != nil {
142 return err
143 }
144
145 // Down
146 if err := g.SetKeybinding(bugTableView, 'j', gocui.ModNone,
147 bt.cursorDown); err != nil {
148 return err
149 }
150 if err := g.SetKeybinding(bugTableView, gocui.KeyArrowDown, gocui.ModNone,
151 bt.cursorDown); err != nil {
152 return err
153 }
154 // Up
155 if err := g.SetKeybinding(bugTableView, 'k', gocui.ModNone,
156 bt.cursorUp); err != nil {
157 return err
158 }
159 if err := g.SetKeybinding(bugTableView, gocui.KeyArrowUp, gocui.ModNone,
160 bt.cursorUp); err != nil {
161 return err
162 }
163
164 // Previous page
165 if err := g.SetKeybinding(bugTableView, 'h', gocui.ModNone,
166 bt.previousPage); err != nil {
167 return err
168 }
169 if err := g.SetKeybinding(bugTableView, gocui.KeyArrowLeft, gocui.ModNone,
170 bt.previousPage); err != nil {
171 return err
172 }
173 if err := g.SetKeybinding(bugTableView, gocui.KeyPgup, gocui.ModNone,
174 bt.previousPage); err != nil {
175 return err
176 }
177 // Next page
178 if err := g.SetKeybinding(bugTableView, 'l', gocui.ModNone,
179 bt.nextPage); err != nil {
180 return err
181 }
182 if err := g.SetKeybinding(bugTableView, gocui.KeyArrowRight, gocui.ModNone,
183 bt.nextPage); err != nil {
184 return err
185 }
186 if err := g.SetKeybinding(bugTableView, gocui.KeyPgdn, gocui.ModNone,
187 bt.nextPage); err != nil {
188 return err
189 }
190
191 // New bug
192 if err := g.SetKeybinding(bugTableView, 'n', gocui.ModNone,
193 bt.newBug); err != nil {
194 return err
195 }
196
197 // Open bug
198 if err := g.SetKeybinding(bugTableView, gocui.KeyEnter, gocui.ModNone,
199 bt.openBug); err != nil {
200 return err
201 }
202
203 // Pull
204 if err := g.SetKeybinding(bugTableView, 'i', gocui.ModNone,
205 bt.pull); err != nil {
206 return err
207 }
208
209 // Push
210 if err := g.SetKeybinding(bugTableView, 'o', gocui.ModNone,
211 bt.push); err != nil {
212 return err
213 }
214
215 // Query
216 if err := g.SetKeybinding(bugTableView, 's', gocui.ModNone,
217 bt.changeQuery); err != nil {
218 return err
219 }
220
221 return nil
222}
223
224func (bt *bugTable) disable(g *gocui.Gui) error {
225 if err := g.DeleteView(bugTableView); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
226 return err
227 }
228 if err := g.DeleteView(bugTableHeaderView); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
229 return err
230 }
231 if err := g.DeleteView(bugTableFooterView); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
232 return err
233 }
234 if err := g.DeleteView(bugTableInstructionView); err != nil && !errors.Is(err, gocui.ErrUnknownView) {
235 return err
236 }
237 return nil
238}
239
240func (bt *bugTable) paginate(max int) error {
241 var err error
242 bt.allIds, err = bt.repo.Bugs().Query(bt.query)
243 if err != nil {
244 return err
245 }
246
247 return bt.doPaginate(max)
248}
249
250func (bt *bugTable) doPaginate(max int) error {
251 // clamp the cursor
252 bt.pageCursor = maxInt(bt.pageCursor, 0)
253 bt.pageCursor = minInt(bt.pageCursor, len(bt.allIds))
254
255 nb := minInt(len(bt.allIds)-bt.pageCursor, max)
256
257 if nb < 0 {
258 bt.excerpts = []*cache.BugExcerpt{}
259 return nil
260 }
261
262 // slice the data
263 ids := bt.allIds[bt.pageCursor : bt.pageCursor+nb]
264
265 bt.excerpts = make([]*cache.BugExcerpt, len(ids))
266
267 for i, id := range ids {
268 excerpt, err := bt.repo.Bugs().ResolveExcerpt(id)
269 if err != nil {
270 return err
271 }
272
273 bt.excerpts[i] = excerpt
274 }
275
276 return nil
277}
278
279func (bt *bugTable) getTableLength() int {
280 return len(bt.excerpts)
281}
282
283func (bt *bugTable) getColumnWidths(maxX int) map[string]int {
284 m := make(map[string]int)
285 m["id"] = 7
286 m["status"] = 6
287
288 left := maxX - 5 - m["id"] - m["status"]
289
290 m["comments"] = 3
291 left -= m["comments"]
292 m["lastEdit"] = 14
293 left -= m["lastEdit"]
294
295 m["author"] = minInt(maxInt(left/3, 15), 10+left/8)
296 m["title"] = maxInt(left-m["author"], 10)
297
298 return m
299}
300
301func (bt *bugTable) render(v *gocui.View, maxX int) {
302 columnWidths := bt.getColumnWidths(maxX)
303
304 for _, excerpt := range bt.excerpts {
305 summaryTxt := fmt.Sprintf("%3d", excerpt.LenComments-1)
306 if excerpt.LenComments-1 <= 0 {
307 summaryTxt = ""
308 }
309 if excerpt.LenComments-1 > 999 {
310 summaryTxt = " ∞"
311 }
312
313 var labelsTxt strings.Builder
314 for _, l := range excerpt.Labels {
315 labelsTxt.WriteString(" ")
316 lc256 := l.Color().Term256()
317 labelsTxt.WriteString(lc256.Escape())
318 labelsTxt.WriteString("◼")
319 labelsTxt.WriteString(lc256.Unescape())
320 }
321
322 author, err := bt.repo.Identities().ResolveExcerpt(excerpt.AuthorId)
323 if err != nil {
324 panic(err)
325 }
326
327 id := text.LeftPadMaxLine(excerpt.Id().Human(), columnWidths["id"], 0)
328 status := text.LeftPadMaxLine(excerpt.Status.String(), columnWidths["status"], 0)
329 labels := text.TruncateMax(labelsTxt.String(), minInt(columnWidths["title"]-2, 10))
330 title := text.LeftPadMaxLine(strings.TrimSpace(excerpt.Title), columnWidths["title"]-text.Len(labels), 0)
331 authorTxt := text.LeftPadMaxLine(author.DisplayName(), columnWidths["author"], 0)
332 comments := text.LeftPadMaxLine(summaryTxt, columnWidths["comments"], 0)
333 lastEdit := text.LeftPadMaxLine(humanize.Time(excerpt.EditTime()), columnWidths["lastEdit"], 1)
334
335 _, _ = fmt.Fprintf(v, "%s %s %s%s %s %s %s\n",
336 colors.Cyan(id),
337 colors.Yellow(status),
338 title,
339 labels,
340 colors.Magenta(authorTxt),
341 comments,
342 lastEdit,
343 )
344 }
345
346 _ = v.SetHighlight(bt.selectCursor, true)
347}
348
349func (bt *bugTable) renderHeader(v *gocui.View, maxX int) {
350 columnWidths := bt.getColumnWidths(maxX)
351
352 id := text.LeftPadMaxLine("ID", columnWidths["id"], 0)
353 status := text.LeftPadMaxLine("STATUS", columnWidths["status"], 0)
354 title := text.LeftPadMaxLine("TITLE", columnWidths["title"], 0)
355 author := text.LeftPadMaxLine("AUTHOR", columnWidths["author"], 0)
356 comments := text.LeftPadMaxLine("CMT", columnWidths["comments"], 0)
357 lastEdit := text.LeftPadMaxLine("LAST EDIT", columnWidths["lastEdit"], 1)
358
359 _, _ = fmt.Fprintf(v, "%s %s %s %s %s %s\n", id, status, title, author, comments, lastEdit)
360}
361
362func (bt *bugTable) renderFooter(v *gocui.View, maxX int) {
363 _, _ = fmt.Fprintf(v, " \nShowing %d of %d bugs", len(bt.excerpts), len(bt.allIds))
364}
365
366func (bt *bugTable) renderHelp(v *gocui.View, maxX int) {
367 _, _ = fmt.Fprint(v, bugTableHelp.Render(maxX))
368}
369
370func (bt *bugTable) cursorDown(g *gocui.Gui, v *gocui.View) error {
371 // If we are at the bottom of the page, switch to the next one.
372 if bt.selectCursor+1 > bt.getTableLength()-1 {
373 _, max := v.Size()
374
375 if bt.pageCursor+max >= len(bt.allIds) {
376 return nil
377 }
378
379 bt.pageCursor += max
380 bt.selectCursor = 0
381
382 return bt.doPaginate(max)
383 }
384
385 bt.selectCursor = minInt(bt.selectCursor+1, bt.getTableLength()-1)
386
387 return nil
388}
389
390func (bt *bugTable) cursorUp(g *gocui.Gui, v *gocui.View) error {
391 // If we are at the top of the page, switch to the previous one.
392 if bt.selectCursor-1 < 0 {
393 _, max := v.Size()
394
395 if bt.pageCursor == 0 {
396 return nil
397 }
398
399 bt.pageCursor = maxInt(0, bt.pageCursor-max)
400 bt.selectCursor = max - 1
401
402 return bt.doPaginate(max)
403 }
404
405 bt.selectCursor = maxInt(bt.selectCursor-1, 0)
406
407 return nil
408}
409
410func (bt *bugTable) cursorClamp(v *gocui.View) error {
411 y := bt.selectCursor
412
413 y = minInt(y, bt.getTableLength()-1)
414 y = maxInt(y, 0)
415
416 bt.selectCursor = y
417
418 return nil
419}
420
421func (bt *bugTable) nextPage(g *gocui.Gui, v *gocui.View) error {
422 _, max := v.Size()
423
424 if bt.pageCursor+max >= len(bt.allIds) {
425 return nil
426 }
427
428 bt.pageCursor += max
429
430 return bt.doPaginate(max)
431}
432
433func (bt *bugTable) previousPage(g *gocui.Gui, v *gocui.View) error {
434 _, max := v.Size()
435
436 if bt.pageCursor == 0 {
437 return nil
438 }
439
440 bt.pageCursor = maxInt(0, bt.pageCursor-max)
441
442 return bt.doPaginate(max)
443}
444
445func (bt *bugTable) newBug(g *gocui.Gui, v *gocui.View) error {
446 return newBugWithEditor(bt.repo)
447}
448
449func (bt *bugTable) openBug(g *gocui.Gui, v *gocui.View) error {
450 if len(bt.excerpts) == 0 {
451 // There are no open bugs, just do nothing
452 return nil
453 }
454 id := bt.excerpts[bt.selectCursor].Id()
455 b, err := bt.repo.Bugs().Resolve(id)
456 if err != nil {
457 return err
458 }
459 ui.showBug.SetBug(b)
460 return ui.activateWindow(ui.showBug)
461}
462
463func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
464 ui.msgPopup.Activate("Pull from remote "+defaultRemote, "...")
465
466 go func() {
467 stdout, err := bt.repo.Fetch(defaultRemote)
468
469 if err != nil {
470 g.Update(func(gui *gocui.Gui) error {
471 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
472 return nil
473 })
474 } else {
475 g.Update(func(gui *gocui.Gui) error {
476 ui.msgPopup.UpdateMessage(stdout)
477 return nil
478 })
479 }
480
481 var buffer bytes.Buffer
482 beginLine := ""
483
484 for result := range bt.repo.MergeAll(defaultRemote) {
485 if result.Status == entity.MergeStatusNothing {
486 continue
487 }
488
489 if result.Err != nil {
490 g.Update(func(gui *gocui.Gui) error {
491 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
492 return nil
493 })
494 } else {
495 _, _ = fmt.Fprintf(&buffer, "%s%s: %s",
496 beginLine, colors.Cyan(result.Entity.Id().Human()), result,
497 )
498
499 beginLine = "\n"
500
501 g.Update(func(gui *gocui.Gui) error {
502 ui.msgPopup.UpdateMessage(buffer.String())
503 return nil
504 })
505 }
506 }
507
508 _, _ = fmt.Fprintf(&buffer, "%sdone", beginLine)
509
510 g.Update(func(gui *gocui.Gui) error {
511 ui.msgPopup.UpdateMessage(buffer.String())
512 return nil
513 })
514
515 }()
516
517 return nil
518}
519
520func (bt *bugTable) push(g *gocui.Gui, v *gocui.View) error {
521 ui.msgPopup.Activate("Push to remote "+defaultRemote, "...")
522
523 go func() {
524 // TODO: make the remote configurable
525 stdout, err := bt.repo.Push(defaultRemote)
526
527 if err != nil {
528 g.Update(func(gui *gocui.Gui) error {
529 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
530 return nil
531 })
532 } else {
533 g.Update(func(gui *gocui.Gui) error {
534 ui.msgPopup.UpdateMessage(stdout)
535 return nil
536 })
537 }
538 }()
539
540 return nil
541}
542
543func (bt *bugTable) changeQuery(g *gocui.Gui, v *gocui.View) error {
544 return editQueryWithEditor(bt)
545}