From 3eaf9fcde3c314a9b05f967d4a55b704d040348e Mon Sep 17 00:00:00 2001 From: k1nho Date: Tue, 15 Aug 2023 16:17:05 -0400 Subject: [PATCH] create pizza show testing go-client Adding general repository info to TUI (issues, forks, size, stars) centralized state manager structure with nested models, and start of contributors model Added contributor model stats and interactivity through table selection in dashboard model tidy improving error handling flow, and adding basic dashboard responsiveness for horizontal layouts tidy --- cmd/root/root.go | 2 + cmd/show/contributors.go | 190 +++++++++++++++++++++ cmd/show/dashboard.go | 360 +++++++++++++++++++++++++++++++++++++++ cmd/show/show.go | 85 +++++++++ cmd/show/styles.go | 42 +++++ cmd/show/tui.go | 85 +++++++++ go.mod | 17 ++ go.sum | 41 +++++ pkg/api/client.go | 14 +- pkg/api/constants.go | 4 +- 10 files changed, 837 insertions(+), 3 deletions(-) create mode 100644 cmd/show/contributors.go create mode 100644 cmd/show/dashboard.go create mode 100644 cmd/show/show.go create mode 100644 cmd/show/styles.go create mode 100644 cmd/show/tui.go diff --git a/cmd/root/root.go b/cmd/root/root.go index e19a6da..cb3d28a 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -10,6 +10,7 @@ import ( "github.com/open-sauced/pizza-cli/cmd/bake" repoquery "github.com/open-sauced/pizza-cli/cmd/repo-query" "github.com/open-sauced/pizza-cli/cmd/version" + "github.com/open-sauced/pizza-cli/cmd/show" "github.com/open-sauced/pizza-cli/pkg/api" ) @@ -30,6 +31,7 @@ func NewRootCommand() (*cobra.Command, error) { cmd.AddCommand(repoquery.NewRepoQueryCommand()) cmd.AddCommand(auth.NewLoginCommand()) cmd.AddCommand(version.NewVersionCommand()) + cmd.AddCommand(show.NewShowCommand()) return cmd, nil } diff --git a/cmd/show/contributors.go b/cmd/show/contributors.go new file mode 100644 index 0000000..3e04ce2 --- /dev/null +++ b/cmd/show/contributors.go @@ -0,0 +1,190 @@ +package show + +import ( + "context" + "errors" + "fmt" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + client "github.com/open-sauced/go-api/client" +) + +// ContributorModel holds all the information related to a contributor +type ContributorModel struct { + username string + userInfo *client.DbUser + userPrs []client.DbPullRequest +} + +type ( + // BackMsg: message to signal main model that we are back to dashboard when backspace is pressed + BackMsg struct{} + + // ContributorErrMsg: message to signal that an error occurred when fetching contributor information + ContributorErrMsg struct { + name string + err error + } +) + +// InitContributor: initializes the contributorModel +func InitContributor(contributorName string) (ContributorModel, error) { + var contributorModel ContributorModel + contributorModel.username = contributorName + + err := contributorModel.fetchUser() + if err != nil { + return contributorModel, err + } + + return contributorModel, nil +} + +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.KeyMsg: + switch msg.String() { + case "backspace": + return m, func() tea.Msg { return BackMsg{} } + case "q", "esc", "ctrl+c", "ctrl+d": + return m, tea.Quit + } + } + return m, cmd +} + +func (m ContributorModel) View() string { + return m.drawContributorView() +} + +// fetchUser: fetches all the user information (general info, and pull requests) +func (m *ContributorModel) fetchUser() error { + var ( + wg sync.WaitGroup + errChan = make(chan error, 2) + ) + + wg.Add(2) + go func() { + defer wg.Done() + userInfo, err := fetchContributorInfo(m.username) + if err != nil { + errChan <- err + return + } + m.userInfo = userInfo + + }() + + go func() { + defer wg.Done() + userPRs, err := fetchContributorPRs(m.username) + if err != nil { + errChan <- err + return + } + m.userPrs = userPRs + }() + + wg.Wait() + close(errChan) + if len(errChan) > 0 { + var allErrors error + for err := range errChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors + } + + return nil +} + +// fetchContributorInfo: fetches the contributor info +func fetchContributorInfo(name string) (*client.DbUser, error) { + config := client.NewConfiguration() + apiClient := client.NewAPIClient(config) + ctx := context.WithValue(context.Background(), client.ContextServerIndex, 1) + + resp, r, err := apiClient.UserServiceAPI.FindOneUserByUserame(ctx, name).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + return resp, nil +} + +// fetchContributorPRs: fetches the contributor pull requests +func fetchContributorPRs(name string) ([]client.DbPullRequest, error) { + config := client.NewConfiguration() + apiClient := client.NewAPIClient(config) + ctx := context.WithValue(context.Background(), client.ContextServerIndex, 1) + + resp, r, err := apiClient.UserServiceAPI.FindContributorPullRequests(ctx, name).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + return resp.Data, nil +} + +// drawContributorView: view of the contributor model +func (m *ContributorModel) drawContributorView() string { + return Viewport.Copy().Render(lipgloss.JoinVertical(lipgloss.Center, m.drawContributorInfo(), m.drawPullRequests())) +} + +// drawContributorInfo: view of the contributor info (open issues, pr velocity, pr count, maintainer) +func (m *ContributorModel) drawContributorInfo() string { + userOpenIssues := fmt.Sprintf("📄 Issues: %d", m.userInfo.OpenIssues) + isUserMaintainer := fmt.Sprintf("🔨 Maintainer: %t", m.userInfo.GetIsMaintainer()) + prVelocity := fmt.Sprintf("🔥 PR Velocity (30d): %d%%", m.userInfo.RecentPullRequestVelocityCount) + prCount := fmt.Sprintf("🚀 PR Count (30d): %d", m.userInfo.RecentPullRequestsCount) + + prStats := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(prVelocity), TextContainer.Render(prCount)) + issuesAndMaintainer := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(userOpenIssues), TextContainer.Render(isUserMaintainer)) + + contributorInfo := lipgloss.JoinHorizontal(lipgloss.Center, prStats, issuesAndMaintainer) + contributorView := lipgloss.JoinVertical(lipgloss.Center, m.userInfo.Login, contributorInfo) + + 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 new file mode 100644 index 0000000..e6a056f --- /dev/null +++ b/cmd/show/dashboard.go @@ -0,0 +1,360 @@ +package show + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + client "github.com/open-sauced/go-api/client" +) + +const ( + newContributorsView = iota + alumniContributorsView +) + +// DashboardModel holds all the information related to the repository queried (issues, stars, new contributors, alumni contributors) +type DashboardModel struct { + newContributorsTable table.Model + alumniContributorsTable table.Model + RepositoryInfo *client.DbRepo + contributorErr string + tableView int + queryOptions [3]int + APIClient *client.APIClient + serverContext context.Context +} + +// SelectMsg: message to signal the main model that we want to go to the contributor model when 'enter' is pressed +type SelectMsg struct { + contributorName string +} + +// FetchRepoInfo: initializes the dashboard model +func InitDashboard(opts *Options) (DashboardModel, error) { + model := DashboardModel{} + err := validateShowQuery(opts) + if err != nil { + return model, err + } + ownerRepo := strings.Split(opts.RepoName, "/") + + resp, r, err := opts.APIClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(opts.ServerContext, ownerRepo[0], ownerRepo[1]).Execute() + if err != nil { + return model, err + } + + if r.StatusCode != 200 { + return model, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + // configuring the dashboardModel + model.RepositoryInfo = resp + model.queryOptions = [3]int{opts.Page, opts.Limit, opts.Range} + model.APIClient = opts.APIClient + model.serverContext = opts.ServerContext + + // Fetching all contributors + err = model.FetchAllContributors() + if err != nil { + return model, err + } + + return model, nil +} + +func (m DashboardModel) Init() tea.Cmd { return nil } + +func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case ContributorErrMsg: + m.contributorErr = fmt.Sprintf("🚧 could not fetch %s: %s", msg.name, msg.err.Error()) + default: + m.contributorErr = "" + + case tea.WindowSizeMsg: + WindowSize = msg + width, _ := m.alumniContributorsTable.Width(), m.alumniContributorsTable.Height() + m.alumniContributorsTable.SetWidth(max(msg.Width-width, 5)) + m.newContributorsTable.SetWidth(max(msg.Width-width, 5)) + + case tea.KeyMsg: + switch msg.String() { + case "right", "l": + m.tableView = (m.tableView + 1) % 2 + case "left", "h": + if m.tableView-1 <= 0 { + m.tableView = 0 + } else { + m.tableView-- + } + case "q", "esc", "ctrl+c", "ctrl+d": + return m, tea.Quit + case "enter": + var contributorName string + switch m.tableView { + case newContributorsView: + if len(m.newContributorsTable.Rows()) > 0 { + contributorName = m.newContributorsTable.SelectedRow()[1] + return m, func() tea.Msg { return SelectMsg{contributorName} } + } + case alumniContributorsView: + if len(m.alumniContributorsTable.Rows()) > 0 { + contributorName = m.alumniContributorsTable.SelectedRow()[1] + return m, func() tea.Msg { return SelectMsg{contributorName} } + } + } + } + + switch m.tableView { + case newContributorsView: + m.newContributorsTable, cmd = m.newContributorsTable.Update(msg) + case alumniContributorsView: + m.alumniContributorsTable, cmd = m.alumniContributorsTable.Update(msg) + } + } + + return m, cmd +} + +func (m DashboardModel) View() string { + return m.drawDashboardView() +} + +// drawTitle: view of PIZZA +func (m *DashboardModel) drawTitle() string { + titleRunes1 := []rune{'█', '█', '█', '█', '█', '█', '╗', ' ', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', ' ', '█', '█', '█', '█', '█', '╗', ' '} + titleRunes2 := []rune{'█', '█', '╔', '═', '═', '█', '█', '╗', '█', '█', '║', '╚', '═', '═', '█', '█', '█', '╔', '╝', '╚', '═', '═', '█', '█', '█', '╔', '╝', '█', '█', '╔', '═', '═', '█', '█', '╗'} + titleRunes3 := []rune{'█', '█', '█', '█', '█', '█', '╔', '╝', '█', '█', '║', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', '█', '█', '█', '█', '█', '█', '█', '║'} + titleRunes4 := []rune{'█', '█', '╔', '═', '═', '═', '╝', ' ', '█', '█', '║', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', '█', '█', '╔', '═', '═', '█', '█', '║'} + titleRunes5 := []rune{'█', '█', '║', ' ', ' ', ' ', ' ', ' ', '█', '█', '║', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '║', ' ', ' ', '█', '█', '║'} + titleRunes6 := []rune{'╚', '═', '╝', ' ', ' ', ' ', ' ', ' ', '╚', '═', '╝', '╚', '═', '═', '═', '═', '═', '═', '╝', '╚', '═', '═', '═', '═', '═', '═', '╝', '╚', '═', '╝', ' ', ' ', '╚', '═', '╝'} + + title1 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes1)) + title2 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes2)) + title3 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes3)) + title4 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes4)) + title5 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes5)) + title6 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes6)) + title := lipgloss.JoinVertical(lipgloss.Center, title1, title2, title3, title4, title5, title6) + titleView := lipgloss.NewStyle().Foreground(Color).Render(title) + + return titleView +} + +// drawRepositoryInfo: view of the repository info (name, stars, size, and issues) +func (m *DashboardModel) drawRepositoryInfo() string { + repoName := lipgloss.NewStyle().Bold(true).AlignHorizontal(lipgloss.Center).Render(fmt.Sprintf("Repository: %s", m.RepositoryInfo.FullName)) + repoStars := fmt.Sprintf("🌟 stars: %d", m.RepositoryInfo.Stars) + repoSize := fmt.Sprintf("💾 size: %dB", m.RepositoryInfo.Size) + repoIssues := fmt.Sprintf("📄 issues: %d", m.RepositoryInfo.Issues) + repoForks := fmt.Sprintf("⑂ forks: %d", m.RepositoryInfo.Forks) + + issuesAndForks := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(repoIssues), TextContainer.Render(repoForks)) + sizeAndStars := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(repoSize), TextContainer.Render(repoStars)) + + repoGeneralSection := lipgloss.JoinHorizontal(lipgloss.Center, issuesAndForks, sizeAndStars) + repositoryInfoView := lipgloss.JoinVertical(lipgloss.Center, repoName, repoGeneralSection) + frame := SquareBorder.Render(repositoryInfoView) + + return frame +} + +// drawMetrics: view of metrics includes. +// - new contributors table +// - alumni contributors table +func (m *DashboardModel) drawMetrics() string { + var newContributorsDisplay, alumniContributorsDisplay string + + switch m.tableView { + case newContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍕 New Contributors"), ActiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), InactiveStyle.Render(m.alumniContributorsTable.View())) + case alumniContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍕 New Contributors"), InactiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), ActiveStyle.Render(m.alumniContributorsTable.View())) + } + + contributorsMetrics := lipgloss.JoinHorizontal(lipgloss.Center, WidgetContainer.Render(newContributorsDisplay), WidgetContainer.Render(alumniContributorsDisplay)) + + return contributorsMetrics +} + +// drawDashboardView: this is the main model view (shows repository info and tables) +func (m *DashboardModel) drawDashboardView() string { + if WindowSize.Width == 0 { + return "Loading..." + } + var wg sync.WaitGroup + + wg.Add(3) + var titleView, repoInfoView, metricsView string + go func() { + defer wg.Done() + titleView = m.drawTitle() + }() + + go func() { + defer wg.Done() + repoInfoView = m.drawRepositoryInfo() + }() + + go func() { + defer wg.Done() + metricsView = m.drawMetrics() + }() + wg.Wait() + + mainView := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView, metricsView, m.contributorErr) + _, h := lipgloss.Size(mainView) + if WindowSize.Height < h { + contentLeft := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView) + mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, metricsView) + } + frame := Viewport.Render(mainView) + return frame +} + +// validateShowQuery: validates fields set to query the contributor tables +func validateShowQuery(opts *Options) error { + if opts.RepoName == "" { + return errors.New("no repository was provided") + } + + if len(strings.Split(opts.RepoName, "/")) != 2 { + return errors.New("wrong repository name format. Must be in the form owner/name, i.e(open-sauced/pizza)") + } + if opts.Limit < 1 { + return errors.New("--limit flag must be a positive integer value") + } + if opts.Range < 1 { + return errors.New("--range flag must be a positive integer value") + } + + if opts.Page < 1 { + return errors.New("--page flag must be a positive integer value") + } + + return nil +} + +// FetchAllContributors: fetchs and sets all the contributors (new, alumni) +func (m *DashboardModel) FetchAllContributors() error { + var ( + errorChan = make(chan error, 2) + wg sync.WaitGroup + ) + + wg.Add(2) + go func() { + defer wg.Done() + newContributors, err := m.FetchNewContributors() + if err != nil { + errorChan <- err + return + } + m.newContributorsTable = setupContributorsTable(newContributors) + }() + + go func() { + defer wg.Done() + alumniContributors, err := m.FetchAlumniContributors() + if err != nil { + errorChan <- err + return + } + m.alumniContributorsTable = setupContributorsTable(alumniContributors) + }() + + wg.Wait() + close(errorChan) + if len(errorChan) > 0 { + var allErrors error + for err := range errorChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors + } + + return nil +} + +// FetchNewContributors: Returns all the new contributors +func (m *DashboardModel) FetchNewContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.NewPullRequestContributors(m.serverContext).Page(int32(m.queryOptions[0])). + Limit(int32(m.queryOptions[1])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil + +} + +// FetchAlumniContributors: Returns all alumni contributors +func (m *DashboardModel) FetchAlumniContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.FindAllChurnPullRequestContributors(m.serverContext). + Page(int32(m.queryOptions[0])).Limit(int32(m.queryOptions[1])). + Range_(int32(m.queryOptions[2])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil +} + +// setupContributorsTable: sets the contributor table UI +func setupContributorsTable(contributors []client.DbPullRequestContributor) table.Model { + columns := []table.Column{ + {Title: "ID", Width: 5}, + {Title: "Name", Width: 20}, + } + + rows := []table.Row{} + + for i, contributor := range contributors { + rows = append(rows, table.Row{strconv.Itoa(i), contributor.AuthorLogin}) + } + + contributorTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(15), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + + s.Selected = s.Selected. + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#FF4500")) + + contributorTable.SetStyles(s) + return contributorTable +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..c7c59a2 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,85 @@ +// Package show contains the bootstrapping and tooling for the pizza show +// cobra command +package show + +import ( + "context" + "errors" + + client "github.com/open-sauced/go-api/client" + "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/spf13/cobra" +) + +// Options are the options for the pizza show command including user +// defined configurations +type Options struct { + // RepoName is the git repo name to get statistics + RepoName string + + // Page is the page to be requested + Page int + + // Limit is the number of records to be retrieved + Limit int + + // Range is the number of days to take into account when retrieving statistics + Range int + + // APIClient is the api client to interface with open sauced api + APIClient *client.APIClient + + ServerContext context.Context +} + +const showLongDesc string = `WARNING: Proof of concept feature. + +The show command accepts the name of a git repository in the format 'owner/name' and uses OpenSauced api +to retrieve metrics of the repository to be displayed as a TUI.` + +// NewShowCommand returns a new cobra command for 'pizza show' +func NewShowCommand() *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "show repository-name [flags]", + Short: "Get visual metrics of a repository", + Long: showLongDesc, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("must specify the URL of a git repository to analyze") + } + opts.RepoName = args[0] + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + var endpoint string + customEndpoint, _ := cmd.Flags().GetString("endpoint") + if customEndpoint != "" { + endpoint = customEndpoint + } + + useBeta, _ := cmd.Flags().GetBool("beta") + if useBeta { + endpoint = api.BetaAPIEndpoint + } + + opts.APIClient = api.NewGoClient(endpoint) + opts.ServerContext = context.TODO() + return run(opts) + }, + } + + cmd.Flags().IntVarP(&opts.Range, "range", "r", 30, "The last N number of days to consider") + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "The number of records to retrieve") + cmd.Flags().IntVarP(&opts.Page, "page", "p", 1, "The page number to retrieve") + + return cmd +} + +func run(opts *Options) error { + // Load the pizza TUI + err := pizzaTUI(opts) + return err +} diff --git a/cmd/show/styles.go b/cmd/show/styles.go new file mode 100644 index 0000000..6262be2 --- /dev/null +++ b/cmd/show/styles.go @@ -0,0 +1,42 @@ +package show + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// WindowSize stores the size of the terminal +var WindowSize tea.WindowSizeMsg + +// STYLES + +// Viewport: The viewport of the tui (my:2, mx:40) +var Viewport = lipgloss.NewStyle().Margin(1, 2) + +// Container: container styling (width: 80, py: 0, px: 5) +var Container = lipgloss.NewStyle().Width(80).Padding(0, 5) + +// WidgetContainer: container for tables, and graphs (py:2, px:2) +var WidgetContainer = lipgloss.NewStyle().Padding(2, 2) + +// SquareBorder: Style to draw a border around a section +var SquareBorder = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(Color) + +// TextContainer: container for text +var TextContainer = lipgloss.NewStyle().Padding(1, 1) + +// TableTitle: The style for table titles (width:25, align-horizontal:center, bold:true) +var TableTitle = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Bold(true) + +// Color: the color palette (Light: #000000, Dark: #FF4500) +var Color = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FF4500"} + +// ActiveStyle: table when selected (border:normal, border-foreground:#FF4500) +var ActiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FF4500")) + +// InactiveStyle: table when not selected (border:normal, border-foreground:#FFFFFF) +var InactiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FFFFFF")) diff --git a/cmd/show/tui.go b/cmd/show/tui.go new file mode 100644 index 0000000..989d70b --- /dev/null +++ b/cmd/show/tui.go @@ -0,0 +1,85 @@ +package show + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + dashboardView = iota + contributorView +) + +type ( + // sessionState serves as the variable to reference when looking for which model the user is in + sessionState int +) + +// MainModel: the main model is the central state manager of the TUI, decides which model is focused based on certain commands +type MainModel struct { + state sessionState + dashboard tea.Model + contributor tea.Model + authorName string +} + +// View: the view of the TUI +func (m MainModel) View() string { + switch m.state { + case contributorView: + return m.contributor.View() + default: + return m.dashboard.View() + } +} + +// Init: initial IO before program start +func (m MainModel) Init() tea.Cmd { return nil } + +// Update: Handle IO and Commands +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case BackMsg: + m.state = dashboardView + case SelectMsg: + m.state = contributorView + m.authorName = msg.contributorName + } + + switch m.state { + case dashboardView: + newDashboard, newCmd := m.dashboard.Update(msg) + m.dashboard = newDashboard + cmd = newCmd + case contributorView: + // TODO: process cmd error if contributor fails to fetch + var err error + m.contributor, err = InitContributor(m.authorName) + if err != nil { + m.state = dashboardView + return m, func() tea.Msg { return ContributorErrMsg{name: m.authorName, err: err} } + } + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor + cmd = newCmd + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func pizzaTUI(opts *Options) error { + dashboardModel, err := InitDashboard(opts) + if err != nil { + return err + } + + model := MainModel{dashboard: dashboardModel} + if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("Error running program: %s", err.Error()) + } + + return nil +} diff --git a/go.mod b/go.mod index 64f3be1..f1cf158 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/open-sauced/pizza-cli go 1.20 require ( + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 github.com/cli/browser v1.2.0 github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 github.com/spf13/cobra v1.7.0 @@ -12,7 +15,21 @@ require ( ) require ( + 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 github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff + github.com/rivo/uniseg v0.2.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 35b5fbe..296ea99 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,18 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +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= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= +github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -11,6 +23,28 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 h1:01dHVodha5BzrMtVmcpPeA4VYbZEsTXQ6m4123zQXJk= github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69/go.mod h1:migYMxlAqcnQy+3eN8mcL0b2tpKy6R+8Zc0lxwk4dKM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff h1:sHfgCRtAg3xzNXoqqJ2MhDLwPj4GhC99ksDYC+K1BtM= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff/go.mod h1:W/TRuLUqYpMvkmElDUQvQ07xlxhK8TOfpwRh8SCAuNA= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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= @@ -19,10 +53,17 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/api/client.go b/pkg/api/client.go index e519936..7cdaf47 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -1,6 +1,10 @@ package api -import "net/http" +import ( + "net/http" + + "github.com/open-sauced/go-api/client" +) type Client struct { // The configured http client for making API requests @@ -18,3 +22,11 @@ func NewClient(endpoint string) *Client { Endpoint: endpoint, } } + +func NewGoClient(endpoint string) *client.APIClient { + config := client.NewConfiguration() + config.Servers = client.ServerConfigurations{ + {URL: endpoint}, + } + return client.NewAPIClient(config) +} diff --git a/pkg/api/constants.go b/pkg/api/constants.go index 66a71e7..5f4a0d0 100644 --- a/pkg/api/constants.go +++ b/pkg/api/constants.go @@ -1,6 +1,6 @@ package api const ( - APIEndpoint = "https://api.opensauced.pizza/v1" - BetaAPIEndpoint = "https://beta.api.opensauced.pizza/v1" + APIEndpoint = "https://api.opensauced.pizza" + BetaAPIEndpoint = "https://beta.api.opensauced.pizza" )