1package termui
2
3import (
4 "bytes"
5 "fmt"
6 "strings"
7
8 "github.com/MichaelMure/go-term-text"
9 "github.com/awesome-gocui/gocui"
10 "github.com/dustin/go-humanize"
11
12 "github.com/MichaelMure/git-bug/cache"
13 "github.com/MichaelMure/git-bug/entity"
14 "github.com/MichaelMure/git-bug/query"
15 "github.com/MichaelMure/git-bug/util/colors"
16)
17
18const bugTableView = "bugTableView"
19const bugTableHeaderView = "bugTableHeaderView"
20const bugTableFooterView = "bugTableFooterView"
21const bugTableInstructionView = "bugTableInstructionView"
22
23const defaultRemote = "origin"
24const defaultQuery = "status:open"
25
26type bugTable struct {
27 repo *cache.RepoCache
28 queryStr string
29 query *query.Query
30 allIds []entity.Id
31 excerpts []*cache.BugExcerpt
32 pageCursor int
33 selectCursor int
34}
35
36func newBugTable(c *cache.RepoCache) *bugTable {
37 q, err := query.Parse(defaultQuery)
38 if err != nil {
39 panic(err)
40 }
41
42 return &bugTable{
43 repo: c,
44 query: q,
45 queryStr: defaultQuery,
46 pageCursor: 0,
47 selectCursor: 0,
48 }
49}
50
51func (bt *bugTable) layout(g *gocui.Gui) error {
52 maxX, maxY := g.Size()
53
54 if maxY < 4 {
55 // window too small !
56 return nil
57 }
58
59 v, err := g.SetView(bugTableHeaderView, -1, -1, maxX, 3, 0)
60
61 if err != nil {
62 if !gocui.IsUnknownView(err) {
63 return err
64 }
65
66 v.Frame = false
67 }
68
69 v.Clear()
70 bt.renderHeader(v, maxX)
71
72 v, err = g.SetView(bugTableView, -1, 1, maxX, maxY-3, 0)
73
74 if err != nil {
75 if !gocui.IsUnknownView(err) {
76 return err
77 }
78
79 v.Frame = false
80 v.SelBgColor = gocui.ColorWhite
81 v.SelFgColor = gocui.ColorBlack
82 }
83
84 _, viewHeight := v.Size()
85 err = bt.paginate(viewHeight)
86 if err != nil {
87 return err
88 }
89
90 err = bt.cursorClamp(v)
91 if err != nil {
92 return err
93 }
94
95 v.Clear()
96 bt.render(v, maxX)
97
98 v, err = g.SetView(bugTableFooterView, -1, maxY-4, maxX, maxY, 0)
99
100 if err != nil {
101 if !gocui.IsUnknownView(err) {
102 return err
103 }
104
105 v.Frame = false
106 }
107
108 v.Clear()
109 bt.renderFooter(v, maxX)
110
111 v, err = g.SetView(bugTableInstructionView, -1, maxY-2, maxX, maxY, 0)
112
113 if err != nil {
114 if !gocui.IsUnknownView(err) {
115 return err
116 }
117
118 v.Frame = false
119 v.FgColor = gocui.ColorWhite
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 := excerpt.EditTime()
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(strings.TrimSpace(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 if len(bt.excerpts) == 0 {
441 // There are no open bugs, just do nothing
442 return nil
443 }
444 id := bt.excerpts[bt.selectCursor].Id
445 b, err := bt.repo.ResolveBug(id)
446 if err != nil {
447 return err
448 }
449 ui.showBug.SetBug(b)
450 return ui.activateWindow(ui.showBug)
451}
452
453func (bt *bugTable) pull(g *gocui.Gui, v *gocui.View) error {
454 ui.msgPopup.Activate("Pull from remote "+defaultRemote, "...")
455
456 go func() {
457 stdout, err := bt.repo.Fetch(defaultRemote)
458
459 if err != nil {
460 g.Update(func(gui *gocui.Gui) error {
461 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
462 return nil
463 })
464 } else {
465 g.Update(func(gui *gocui.Gui) error {
466 ui.msgPopup.UpdateMessage(stdout)
467 return nil
468 })
469 }
470
471 var buffer bytes.Buffer
472 beginLine := ""
473
474 for result := range bt.repo.MergeAll(defaultRemote) {
475 if result.Status == entity.MergeStatusNothing {
476 continue
477 }
478
479 if result.Err != nil {
480 g.Update(func(gui *gocui.Gui) error {
481 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
482 return nil
483 })
484 } else {
485 _, _ = fmt.Fprintf(&buffer, "%s%s: %s",
486 beginLine, colors.Cyan(result.Entity.Id().Human()), result,
487 )
488
489 beginLine = "\n"
490
491 g.Update(func(gui *gocui.Gui) error {
492 ui.msgPopup.UpdateMessage(buffer.String())
493 return nil
494 })
495 }
496 }
497
498 _, _ = fmt.Fprintf(&buffer, "%sdone", beginLine)
499
500 g.Update(func(gui *gocui.Gui) error {
501 ui.msgPopup.UpdateMessage(buffer.String())
502 return nil
503 })
504
505 }()
506
507 return nil
508}
509
510func (bt *bugTable) push(g *gocui.Gui, v *gocui.View) error {
511 ui.msgPopup.Activate("Push to remote "+defaultRemote, "...")
512
513 go func() {
514 // TODO: make the remote configurable
515 stdout, err := bt.repo.Push(defaultRemote)
516
517 if err != nil {
518 g.Update(func(gui *gocui.Gui) error {
519 ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
520 return nil
521 })
522 } else {
523 g.Update(func(gui *gocui.Gui) error {
524 ui.msgPopup.UpdateMessage(stdout)
525 return nil
526 })
527 }
528 }()
529
530 return nil
531}
532
533func (bt *bugTable) changeQuery(g *gocui.Gui, v *gocui.View) error {
534 return editQueryWithEditor(bt)
535}