From 159f0d96fdb4a7b9a2139dee23d2f85d363a146e Mon Sep 17 00:00:00 2001 From: Ankit Pokhrel Date: Tue, 29 Dec 2020 19:18:20 +0100 Subject: [PATCH] feat: Issue view mode --- internal/view/epic.go | 45 ++++++++++------ internal/view/helper.go | 37 +++++++++---- internal/view/issue.go | 14 +++-- internal/view/issues.go | 25 ++++++++- internal/view/sprint.go | 47 ++++++++++------ pkg/tui/helper.go | 33 ------------ pkg/tui/preview.go | 116 +++++++++++++++++++++++----------------- pkg/tui/table.go | 72 +++++++++++++++++++++---- 8 files changed, 244 insertions(+), 145 deletions(-) diff --git a/internal/view/epic.go b/internal/view/epic.go index f189aeac..da415536 100644 --- a/internal/view/epic.go +++ b/internal/view/epic.go @@ -3,6 +3,8 @@ package view import ( "fmt" + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" "github.com/ankitpokhrel/jira-cli/pkg/jira" "github.com/ankitpokhrel/jira-cli/pkg/tui" ) @@ -17,22 +19,42 @@ type EpicList struct { Server string Data []*jira.Issue Issues EpicIssueFunc - - issueCache map[string]tui.TableData } // Render renders the epic explorer view. func (el EpicList) Render() error { - data := el.data() + renderer, err := MDRenderer() + if err != nil { + return err + } + data := el.data() view := tui.NewPreview( tui.WithPreviewFooterText(fmt.Sprintf("Showing %d of %d results for project \"%s\"", len(el.Data), el.Total, el.Project)), tui.WithInitialText(helpText), tui.WithSidebarSelectedFunc(navigate(el.Server)), - tui.WithContentTableOpts(tui.WithSelectedFunc(navigate(el.Server))), + tui.WithContentTableOpts( + tui.WithSelectedFunc(navigate(el.Server)), + tui.WithViewModeFunc(func(r, c int, d interface{}) error { + issue := func() *jira.Issue { + s := cmdutil.Info("Fetching issue details...") + defer s.Stop() + + dt := d.(tui.TableData) + issue, _ := api.Client(jira.Config{Debug: true}).GetIssue(dt[r][1]) + + return issue + }() + out, err := renderer.Render(Issue{Data: issue}.String()) + if err != nil { + return err + } + return PagerOut(out) + }), + ), ) - return view.Render(data) + return view.Paint(data) } func (el EpicList) data() []tui.PreviewData { @@ -51,17 +73,8 @@ func (el EpicList) data() []tui.PreviewData { Key: issue.Key, Menu: fmt.Sprintf("āž¤ %s: %s", issue.Key, prepareTitle(issue.Fields.Summary)), Contents: func(key string) interface{} { - if el.issueCache == nil { - el.issueCache = make(map[string]tui.TableData) - } - - if _, ok := el.issueCache[key]; !ok { - issues := el.Issues(key) - - el.issueCache[key] = el.tabularize(issues) - } - - return el.issueCache[key] + issues := el.Issues(key) + return el.tabularize(issues) }, }) } diff --git a/internal/view/helper.go b/internal/view/helper.go index 74ff0ee8..3bb8138b 100644 --- a/internal/view/helper.go +++ b/internal/view/helper.go @@ -10,26 +10,36 @@ import ( "text/tabwriter" "time" + "github.com/charmbracelet/glamour" "github.com/pkg/browser" "github.com/ankitpokhrel/jira-cli/pkg/tui" ) -const helpText = `Use up and down arrow keys or 'j' and 'k' letters to navigate through the list. - - Press 'w' to toggle focus between the sidebar and the contents screen. On contents screen, - you can use arrow keys or 'j', 'k', 'h', and 'l' letters to navigate through the epic issue list. - - Press ENTER to open the selected issue in the browser. - +const ( + wordWrap = 120 + helpText = `USAGE + ----- + + The layout contains 2 sections, viz: Sidebar and Contents screen. + + You can use up and down arrow keys or 'j' and 'k' letters to navigate through the sidebar. + Press 'w' to toggle focus between the sidebar and the contents screen. + + On contents screen: + - Use arrow keys or 'j', 'k', 'h', and 'l' letters to navigate through the issue list. + - Use 'g' and 'SHIFT+G' to quickly navigate to the top and bottom respectively. + - Press 'v' to view selected issue details. + - Hit ENTER to open the selected issue in a browser. + Press 'q' / ESC / CTRL+C to quit.` +) func formatDateTime(dt, format string) string { t, err := time.Parse(format, dt) if err != nil { return dt } - return t.Format("2006-01-02 15:04:05") } @@ -38,7 +48,6 @@ func formatDateTimeHuman(dt, format string) string { if err != nil { return dt } - return t.Format("Mon, 02 Jan 06") } @@ -80,14 +89,12 @@ func navigate(server string) tui.SelectedFunc { func renderPlain(w io.Writer, data tui.TableData) error { for _, items := range data { n := len(items) - for j, v := range items { _, _ = fmt.Fprintf(w, "%s", v) if j != n-1 { _, _ = fmt.Fprintf(w, "\t") } } - _, _ = fmt.Fprintln(w) } @@ -151,3 +158,11 @@ func PagerOut(out string) error { cmd.Stdout = os.Stdout return cmd.Run() } + +// MDRenderer constructs markdown renderer. +func MDRenderer() (*glamour.TermRenderer, error) { + return glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(wordWrap), + ) +} diff --git a/internal/view/issue.go b/internal/view/issue.go index 48a8f529..0a713e69 100644 --- a/internal/view/issue.go +++ b/internal/view/issue.go @@ -13,8 +13,6 @@ import ( "github.com/ankitpokhrel/jira-cli/pkg/tui" ) -const wordWrap = 120 - // Issue is a list view for issues. type Issue struct { Data *jira.Issue @@ -28,10 +26,7 @@ func (i Issue) Render() error { } data := i.data() - r, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(wordWrap), - ) + r, err := MDRenderer() if err != nil { return err } @@ -43,6 +38,10 @@ func (i Issue) Render() error { } func (i Issue) data() tui.TextData { + return tui.TextData(i.String()) +} + +func (i Issue) String() string { as := i.Data.Fields.Assignee.Name if as == "" { as = "Unassigned" @@ -64,7 +63,7 @@ func (i Issue) data() tui.TextData { tr := adf.NewTranslator(i.Data.Fields.Description.(*adf.ADF), &adf.MarkdownTranslator{}) desc = tr.Translate() } - dt := fmt.Sprintf( + return fmt.Sprintf( "%s %s %s %s āŒ› %s šŸ‘· %s\n# %s\nā±ļø %s šŸ”Ž %s šŸš€ %s šŸ·ļø %s\n\n-----------\n%s", iti, it, sti, st, formatDateTimeHuman(i.Data.Fields.Updated, jira.RFC3339), as, i.Data.Fields.Summary, @@ -72,7 +71,6 @@ func (i Issue) data() tui.TextData { i.Data.Fields.Priority.Name, lbl, desc, ) - return tui.TextData(dt) } // renderPlain renders the issue in plain view. diff --git a/internal/view/issues.go b/internal/view/issues.go index e764ba58..4ad285b0 100644 --- a/internal/view/issues.go +++ b/internal/view/issues.go @@ -7,6 +7,8 @@ import ( "strings" "text/tabwriter" + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" "github.com/ankitpokhrel/jira-cli/pkg/jira" "github.com/ankitpokhrel/jira-cli/pkg/tui" ) @@ -40,16 +42,35 @@ func (l IssueList) Render() error { return l.renderPlain(w) } - data := l.data() + renderer, err := MDRenderer() + if err != nil { + return err + } + data := l.data() view := tui.NewTable( tui.WithColPadding(colPadding), tui.WithMaxColWidth(maxColWidth), tui.WithTableFooterText(fmt.Sprintf("Showing %d of %d results for project \"%s\"", len(data)-1, l.Total, l.Project)), tui.WithSelectedFunc(navigate(l.Server)), + tui.WithViewModeFunc(func(r, c int, _ interface{}) error { + issue := func() *jira.Issue { + s := cmdutil.Info("Fetching issue details...") + defer s.Stop() + + issue, _ := api.Client(jira.Config{Debug: true}).GetIssue(data[r][1]) + + return issue + }() + out, err := renderer.Render(Issue{Data: issue}.String()) + if err != nil { + return err + } + return PagerOut(out) + }), ) - return view.Render(data) + return view.Paint(data) } // renderPlain renders the issue in plain view. diff --git a/internal/view/sprint.go b/internal/view/sprint.go index 68be3731..c8bb74a7 100644 --- a/internal/view/sprint.go +++ b/internal/view/sprint.go @@ -8,6 +8,8 @@ import ( "text/tabwriter" "time" + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" "github.com/ankitpokhrel/jira-cli/pkg/jira" "github.com/ankitpokhrel/jira-cli/pkg/tui" ) @@ -23,14 +25,16 @@ type SprintList struct { Data []*jira.Sprint Issues SprintIssueFunc Display DisplayFormat - - issueCache map[string]tui.TableData } // Render renders the sprint explorer view. func (sl SprintList) Render() error { - data := sl.data() + renderer, err := MDRenderer() + if err != nil { + return err + } + data := sl.data() view := tui.NewPreview( tui.WithPreviewFooterText( fmt.Sprintf( @@ -39,10 +43,28 @@ func (sl SprintList) Render() error { ), ), tui.WithInitialText(helpText), - tui.WithContentTableOpts(tui.WithSelectedFunc(navigate(sl.Server))), + tui.WithContentTableOpts( + tui.WithSelectedFunc(navigate(sl.Server)), + tui.WithViewModeFunc(func(r, c int, d interface{}) error { + issue := func() *jira.Issue { + s := cmdutil.Info("Fetching issue details...") + defer s.Stop() + + dt := d.(tui.TableData) + issue, _ := api.Client(jira.Config{Debug: true}).GetIssue(dt[r][1]) + + return issue + }() + out, err := renderer.Render(Issue{Data: issue}.String()) + if err != nil { + return err + } + return PagerOut(out) + }), + ), ) - return view.Render(data) + return view.Paint(data) } // RenderInTable renders the list in table view. @@ -65,7 +87,7 @@ func (sl SprintList) RenderInTable() error { ), ) - return view.Render(data) + return view.Paint(data) } // renderPlain renders the issue in plain view. @@ -97,17 +119,8 @@ func (sl SprintList) data() []tui.PreviewData { formatDateTimeHuman(s.EndDate, time.RFC3339), ), Contents: func(key string) interface{} { - if sl.issueCache == nil { - sl.issueCache = make(map[string]tui.TableData) - } - - if _, ok := sl.issueCache[key]; !ok { - issues := sl.Issues(bid, sid) - - sl.issueCache[key] = sl.tabularize(issues) - } - - return sl.issueCache[key] + issues := sl.Issues(bid, sid) + return sl.tabularize(issues) }, }) } diff --git a/pkg/tui/helper.go b/pkg/tui/helper.go index 79bd9814..2ba34bc5 100644 --- a/pkg/tui/helper.go +++ b/pkg/tui/helper.go @@ -3,9 +3,6 @@ package tui import ( "bufio" "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" ) func pad(in string, n uint) string { @@ -41,33 +38,3 @@ func splitText(s string) []string { return lines } - -func renderTableHeader(t *Table, data []string) { - style := tcell.StyleDefault.Bold(true).Background(tcell.ColorDarkCyan) - - for c := 0; c < len(data); c++ { - text := " " + data[c] - - cell := tview.NewTableCell(text). - SetStyle(style). - SetSelectable(false). - SetMaxWidth(int(t.maxColWidth)). - SetTextColor(tcell.ColorSnow) - - t.view.SetCell(0, c, cell) - } -} - -func renderTableCell(t *Table, data [][]string) { - rows, cols := len(data), len(data[0]) - - for r := 1; r < rows; r++ { - for c := 0; c < cols; c++ { - cell := tview.NewTableCell(pad(data[r][c], t.colPad)). - SetMaxWidth(int(t.maxColWidth)). - SetTextColor(tcell.ColorDefault) - - t.view.SetCell(r, c, cell) - } - } -} diff --git a/pkg/tui/preview.go b/pkg/tui/preview.go index e15a0deb..a9d50861 100644 --- a/pkg/tui/preview.go +++ b/pkg/tui/preview.go @@ -1,6 +1,8 @@ package tui import ( + "os" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -18,14 +20,16 @@ type PreviewData struct { // // It contains 2 tables internally, viz: sidebar and contents. type Preview struct { - screen *Screen - painter *tview.Grid - sidebar *tview.Table - contents *Table - footer *tview.TextView - initialText string - footerText string - selectedFunc SelectedFunc + screen *Screen + painter *tview.Grid + sidebar *tview.Table + contents *Table + footer *tview.TextView + data []PreviewData + initialText string + footerText string + sidebarSelectedFunc SelectedFunc + contentsCache map[string]interface{} } // PreviewOption is a functional option that wraps preview properties. @@ -36,8 +40,9 @@ func NewPreview(opts ...PreviewOption) *Preview { tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault pv := Preview{ - screen: NewScreen(), - contents: NewTable(), + screen: NewScreen(), + contents: NewTable(), + contentsCache: make(map[string]interface{}), } for _, opt := range opts { opt(&pv) @@ -64,7 +69,7 @@ func WithPreviewFooterText(text string) PreviewOption { // WithSidebarSelectedFunc sets a function that is called when any option in sidebar is selected. func WithSidebarSelectedFunc(fn SelectedFunc) PreviewOption { return func(p *Preview) { - p.selectedFunc = fn + p.sidebarSelectedFunc = fn } } @@ -77,12 +82,14 @@ func WithContentTableOpts(opts ...TableOption) PreviewOption { } } -// Render renders the preview layout. -func (pv *Preview) Render(pd []PreviewData) error { +// Paint paints the preview layout. +func (pv *Preview) Paint(pd []PreviewData) error { if len(pd) == 0 { return errNoData } + pv.data = pd + pv.sidebar.SetSelectionChangedFunc(func(r, c int) { pv.contents.view.Clear() pv.printText("Loading...") @@ -90,10 +97,10 @@ func (pv *Preview) Render(pd []PreviewData) error { go pv.renderContents(pd[r]) }) - if pv.selectedFunc != nil { + if pv.sidebarSelectedFunc != nil { pv.sidebar.SetSelectedFunc(func(r, c int) { if r > 0 { - pv.selectedFunc(r, c, pd[r]) + pv.sidebarSelectedFunc(r, c, pd[r]) } }) } @@ -122,27 +129,25 @@ func (pv *Preview) renderContents(pd PreviewData) { return } - switch v := pd.Contents(pd.Key).(type) { + if _, ok := pv.contentsCache[pd.Key]; !ok { + pv.contentsCache[pd.Key] = pd.Contents(pd.Key) + } + + switch v := pv.contentsCache[pd.Key].(type) { case string: pv.printText(v) case TableData: + data := pv.contentsCache[pd.Key].(TableData) + pv.screen.QueueUpdateDraw(func() { pv.contents.view.Clear() - data := pd.Contents(pd.Key).(TableData) if len(data) == 1 { pv.printText("No results to show.") return } - if pv.contents.selectedFunc != nil { - pv.contents.view.SetSelectedFunc(func(r, c int) { - pv.contents.selectedFunc(r, c, data) - }) - } - - renderTableHeader(pv.contents, data[0]) - renderTableCell(pv.contents, data) + pv.contents.render(data) }) } } @@ -161,21 +166,52 @@ func (pv *Preview) init() { AddItem(tview.NewTextView(), 1, 0, 1, 1, 0, 0, false). // Dummy view to fake row padding. AddItem(pv.footer, 2, 0, 1, 3, 0, 0, false) - pv.initLayout(pv.sidebar, pv.contents.view) - pv.initLayout(pv.contents.view, pv.sidebar) + pv.initLayout(pv.sidebar) + pv.initLayout(pv.contents.view) } func (pv *Preview) initSidebarView() { pv.sidebar = tview.NewTable() + + pv.sidebar.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyRune { + switch ev.Rune() { + case 'q': + pv.screen.Stop() + os.Exit(0) + case 'w': + pv.screen.SetFocus(pv.contents.view) + } + } + return ev + }) } func (pv *Preview) initContentsView() { - contents := tview.NewTable() - - contents.SetBorder(true). + pv.contents.view. + SetBorder(true). SetBorderColor(tcell.ColorDarkGray) - pv.contents.view = contents + pv.contents.view.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyRune { + switch ev.Rune() { + case 'q': + pv.screen.Stop() + os.Exit(0) + case 'w': + pv.screen.SetFocus(pv.sidebar) + case 'v': + if pv.contents.viewModeFunc != nil { + sr, _ := pv.sidebar.GetSelection() + r, c := pv.contents.view.GetSelection() + contents := pv.contentsCache[pv.data[sr].Key] + + pv.screen.Suspend(func() { _ = pv.contents.viewModeFunc(r, c, contents) }) + } + } + } + return ev + }) } func (pv *Preview) initFooterView() { @@ -187,7 +223,7 @@ func (pv *Preview) initFooterView() { pv.footer = view } -func (pv *Preview) initLayout(view *tview.Table, nextView *tview.Table) { +func (pv *Preview) initLayout(view *tview.Table) { view.SetSelectable(true, false). SetSelectedStyle(tcell.StyleDefault.Bold(true).Dim(true)) @@ -197,22 +233,6 @@ func (pv *Preview) initLayout(view *tview.Table, nextView *tview.Table) { } }) - view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyRune { - switch event.Rune() { - case 'q': - pv.screen.Stop() - case 'w': - if view.HasFocus() { - pv.screen.SetFocus(nextView) - } else { - pv.screen.SetFocus(view) - } - } - } - return event - }) - view.SetFixed(1, 1) } diff --git a/pkg/tui/table.go b/pkg/tui/table.go index 91f96f4b..c6064e47 100644 --- a/pkg/tui/table.go +++ b/pkg/tui/table.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -17,19 +18,24 @@ var errNoData = fmt.Errorf("no data") // SelectedFunc is fired when a user press enter key in the table cell. type SelectedFunc func(row, column int, data interface{}) +// ViewModeFunc sets view mode handler func which gets triggered when a user press 'v'. +type ViewModeFunc func(row, column int, data interface{}) error + // TableData is the data to be displayed in a table. type TableData [][]string // Table is a table layout. type Table struct { screen *Screen - painter *tview.Grid + painter tview.Primitive view *tview.Table footer *tview.TextView + data TableData colPad uint maxColWidth uint footerText string selectedFunc SelectedFunc + viewModeFunc ViewModeFunc } // TableOption is a functional option to wrap table properties. @@ -89,22 +95,31 @@ func WithSelectedFunc(fn SelectedFunc) TableOption { } } -// Render renders the table layout. First row is treated as a table header. -func (t *Table) Render(data TableData) error { +// WithViewModeFunc sets a func that is triggered when user a press 'v'. +func WithViewModeFunc(fn ViewModeFunc) TableOption { + return func(t *Table) { + t.viewModeFunc = fn + } +} + +// Paint paints the table layout. First row is treated as a table header. +func (t *Table) Paint(data TableData) error { if len(data) == 0 { return errNoData } + t.data = data + t.render(data) + return t.screen.Paint(t.painter) +} +func (t *Table) render(data TableData) { if t.selectedFunc != nil { t.view.SetSelectedFunc(func(r, c int) { t.selectedFunc(r, c, data) }) } - renderTableHeader(t, data[0]) renderTableCell(t, data) - - return t.screen.Paint(t.painter) } func (t *Table) initFooterView() { @@ -126,14 +141,51 @@ func (t *Table) initTableView() { if key == tcell.KeyEsc { t.screen.Stop() } - }).SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyRune && event.Rune() == 'q' { - t.screen.Stop() + }).SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyRune { + switch ev.Rune() { + case 'q': + t.screen.Stop() + os.Exit(0) + case 'v': + r, c := t.view.GetSelection() + t.screen.Suspend(func() { _ = t.viewModeFunc(r, c, t.data) }) + } } - return event + return ev }) view.SetFixed(1, 1) t.view = view } + +func renderTableHeader(t *Table, data []string) { + style := tcell.StyleDefault.Bold(true).Background(tcell.ColorDarkCyan) + + for c := 0; c < len(data); c++ { + text := " " + data[c] + + cell := tview.NewTableCell(text). + SetStyle(style). + SetSelectable(false). + SetMaxWidth(int(t.maxColWidth)). + SetTextColor(tcell.ColorSnow) + + t.view.SetCell(0, c, cell) + } +} + +func renderTableCell(t *Table, data [][]string) { + rows, cols := len(data), len(data[0]) + + for r := 1; r < rows; r++ { + for c := 0; c < cols; c++ { + cell := tview.NewTableCell(pad(data[r][c], t.colPad)). + SetMaxWidth(int(t.maxColWidth)). + SetTextColor(tcell.ColorDefault) + + t.view.SetCell(r, c, cell) + } + } +}