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