diff --git a/cmd/show/styles.go b/cmd/show/constants.go similarity index 59% rename from cmd/show/styles.go rename to cmd/show/constants.go index 6262be2..9c68085 100644 --- a/cmd/show/styles.go +++ b/cmd/show/constants.go @@ -1,6 +1,8 @@ package show import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -8,9 +10,13 @@ import ( // WindowSize stores the size of the terminal var WindowSize tea.WindowSizeMsg -// STYLES +// Keymaps +var OpenPR = key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open pr")) +var BackToDashboard = key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "back")) +var ToggleHelpMenu = key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "toggle help")) -// Viewport: The viewport of the tui (my:2, mx:40) +// STYLES +// Viewport: The viewport of the tui (my:2, mx:2) var Viewport = lipgloss.NewStyle().Margin(1, 2) // Container: container styling (width: 80, py: 0, px: 5) @@ -40,3 +46,18 @@ var ActiveStyle = lipgloss.NewStyle(). var InactiveStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("#FFFFFF")) + +// ItemStyle: style applied to items in a list.Model +var ItemStyle = lipgloss.NewStyle().PaddingLeft(4) + +// SelectedItemStyle: style applied when the item is selected in a list.Model +var SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(Color) + +// ListItemTitle: style for the list.Model title +var ListItemTitleStyle = lipgloss.NewStyle().MarginLeft(2) + +// PaginationStyle: style for pagination of list.Model +var PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + +// HelpStyle: style for help menu +var HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) diff --git a/cmd/show/contributors.go b/cmd/show/contributors.go index 9fa2677..03103f9 100644 --- a/cmd/show/contributors.go +++ b/cmd/show/contributors.go @@ -4,18 +4,62 @@ import ( "context" "errors" "fmt" + "io" + "strings" "sync" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/cli/browser" client "github.com/open-sauced/go-api/client" ) +// prItem: type for pull request to satisfy the list.Item interface +type prItem client.DbPullRequest + +func (i prItem) FilterValue() string { return i.Title } +func (i prItem) GetRepoName() string { + if i.FullName != nil { + return *i.FullName + } + return "" +} + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 1 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(prItem) + if !ok { + return + } + + prTitle := i.Title + if len(prTitle) >= 60 { + prTitle = fmt.Sprintf("%s...", prTitle[:60]) + } + + str := fmt.Sprintf("#%d %s\n%s\n(%s)", i.Number, i.GetRepoName(), prTitle, i.State) + + fn := ItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return SelectedItemStyle.Render("🍕 " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + // ContributorModel holds all the information related to a contributor type ContributorModel struct { username string userInfo *client.DbUser - userPrs []client.DbPullRequest + prList list.Model APIClient *client.APIClient serverContext context.Context } @@ -45,6 +89,8 @@ func (m ContributorModel) Init() tea.Cmd { return nil } func (m ContributorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + WindowSize = msg case SelectMsg: m.username = msg.contributorName model, err := m.fetchUser() @@ -54,12 +100,25 @@ func (m ContributorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return model, func() tea.Msg { return SuccessMsg{} } case tea.KeyMsg: switch msg.String() { - case "backspace": + case "B": return m, func() tea.Msg { return BackMsg{} } - case "q", "esc", "ctrl+c", "ctrl+d": + case "H": + m.prList.SetShowHelp(!m.prList.ShowHelp()) + return m, nil + case "O": + pr, ok := m.prList.SelectedItem().(prItem) + if ok { + err := browser.OpenURL(fmt.Sprintf("https://github.com/%s/pull/%d", pr.GetRepoName(), pr.Number)) + if err != nil { + fmt.Println("could not open pull request in browser") + } + } + case "q", "ctrl+c", "ctrl+d": return m, tea.Quit + } } + m.prList, cmd = m.prList.Update(msg) return m, cmd } @@ -77,24 +136,21 @@ func (m *ContributorModel) fetchUser() (tea.Model, error) { wg.Add(1) go func() { defer wg.Done() - userInfo, err := m.fetchContributorInfo(m.username) + err := m.fetchContributorInfo(m.username) if err != nil { errChan <- err return } - m.userInfo = userInfo - }() wg.Add(1) go func() { defer wg.Done() - userPRs, err := m.fetchContributorPRs(m.username) + err := m.fetchContributorPRs(m.username) if err != nil { errChan <- err return } - m.userPrs = userPRs }() wg.Wait() @@ -111,36 +167,68 @@ func (m *ContributorModel) fetchUser() (tea.Model, error) { } // fetchContributorInfo: fetches the contributor info -func (m *ContributorModel) fetchContributorInfo(name string) (*client.DbUser, error) { +func (m *ContributorModel) fetchContributorInfo(name string) error { resp, r, err := m.APIClient.UserServiceAPI.FindOneUserByUserame(m.serverContext, name).Execute() if err != nil { - return nil, err + return err } if r.StatusCode != 200 { - return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + return fmt.Errorf("HTTP failed: %d", r.StatusCode) } - return resp, nil + m.userInfo = resp + return nil } -// fetchContributorPRs: fetches the contributor pull requests -func (m *ContributorModel) fetchContributorPRs(name string) ([]client.DbPullRequest, error) { - resp, r, err := m.APIClient.UserServiceAPI.FindContributorPullRequests(m.serverContext, name).Execute() +// fetchContributorPRs: fetches the contributor pull requests and creates pull request list +func (m *ContributorModel) fetchContributorPRs(name string) error { + resp, r, err := m.APIClient.UserServiceAPI.FindContributorPullRequests(m.serverContext, name).Limit(10).Execute() if err != nil { - return nil, err + return err } if r.StatusCode != 200 { - return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + return fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + // create contributor pull request list + var items []list.Item + for _, pr := range resp.Data { + items = append(items, prItem(pr)) } - return resp.Data, nil + l := list.New(items, itemDelegate{}, WindowSize.Width, 14) + l.Title = "✨ Latest Pull Requests" + l.Styles.Title = ListItemTitleStyle + l.Styles.HelpStyle = HelpStyle + l.SetShowStatusBar(false) + l.SetStatusBarItemName("pull request", "pull requests") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + OpenPR, + BackToDashboard, + ToggleHelpMenu, + } + } + + m.prList = l + return nil } // drawContributorView: view of the contributor model func (m *ContributorModel) drawContributorView() string { - return Viewport.Copy().Render(lipgloss.JoinVertical(lipgloss.Center, m.drawContributorInfo(), m.drawPullRequests())) + contributorInfo := m.drawContributorInfo() + + contributorView := lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().PaddingLeft(2).Render(contributorInfo), + WidgetContainer.Render(m.prList.View())) + + _, h := lipgloss.Size(contributorView) + if WindowSize.Height < h { + contributorView = lipgloss.JoinHorizontal(lipgloss.Center, contributorInfo, m.prList.View()) + } + + return contributorView } // drawContributorInfo: view of the contributor info (open issues, pr velocity, pr count, maintainer) @@ -158,31 +246,3 @@ func (m *ContributorModel) drawContributorInfo() string { return SquareBorder.Render(contributorView) } - -// drawPullRequests: view of the contributor pull requests (draws the last 5 pull requests) -func (m *ContributorModel) drawPullRequests() string { - if len(m.userPrs) == 0 { - return "" - } - - pullRequests := []string{} - var numberOfPrs int - - if len(m.userPrs) > 5 { - numberOfPrs = 5 - } else { - numberOfPrs = len(m.userPrs) - } - - for i := 0; i < numberOfPrs; i++ { - prContainer := TextContainer.Render(fmt.Sprintf("#%d %s\n%s\n(%s)", m.userPrs[i].Number, m.userPrs[i].GetFullName(), - m.userPrs[i].Title, m.userPrs[i].State)) - pullRequests = append(pullRequests, prContainer) - } - - formattedPrs := lipgloss.JoinVertical(lipgloss.Left, pullRequests...) - title := lipgloss.NewStyle().AlignHorizontal(lipgloss.Center).Render("✨ Latest Pull Requests") - - pullRequestView := lipgloss.JoinVertical(lipgloss.Center, title, formattedPrs) - return WidgetContainer.Render(pullRequestView) -} diff --git a/cmd/show/dashboard.go b/cmd/show/dashboard.go index cda0ea5..14efb55 100644 --- a/cmd/show/dashboard.go +++ b/cmd/show/dashboard.go @@ -196,7 +196,8 @@ func (m *DashboardModel) drawDashboardView() string { _, h := lipgloss.Size(mainView) if WindowSize.Height < h { contentLeft := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView) - mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, metricsView) + contentRight := lipgloss.JoinVertical(lipgloss.Center, metricsView, m.contributorErr) + mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, contentRight) } frame := Viewport.Render(mainView) return frame diff --git a/cmd/show/tui.go b/cmd/show/tui.go index 8fbb351..141367e 100644 --- a/cmd/show/tui.go +++ b/cmd/show/tui.go @@ -51,17 +51,14 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { - case tea.WindowSizeMsg: - WindowSize.Width = msg.Width - WindowSize.Height = msg.Height case BackMsg: m.state = dashboardView + case SuccessMsg: + m.state = contributorView case SelectMsg: newContributor, newCmd := m.contributor.Update(msg) m.contributor = newContributor cmds = append(cmds, newCmd) - case SuccessMsg: - m.state = contributorView } switch m.state { @@ -70,7 +67,8 @@ func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dashboard = newDashboard cmd = newCmd case contributorView: - _, newCmd := m.contributor.Update(msg) + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor cmd = newCmd } cmds = append(cmds, cmd) diff --git a/go.mod b/go.mod index 4fcfee1..c589872 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/google/uuid v1.3.0 // indirect @@ -29,6 +30,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.9.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index 9e49805..eadba7c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= @@ -17,6 +19,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -44,6 +47,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=