diff --git a/cmd/root.go b/cmd/root.go index 338b90a4..e8b102cd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/muesli/termenv" "github.com/spf13/cobra" + "github.com/dlvhdr/gh-dash/v4/config" "github.com/dlvhdr/gh-dash/v4/ui" "github.com/dlvhdr/gh-dash/v4/ui/markdown" ) @@ -35,6 +36,7 @@ var ( Use: "gh dash", Short: "A gh extension that shows a configurable dashboard of pull requests and issues.", Version: "", + Args: cobra.MaximumNArgs(1), } ) @@ -45,7 +47,7 @@ func Execute() { } } -func createModel(configPath string, debug bool) (ui.Model, *os.File) { +func createModel(repoPath *string, configPath string, debug bool) (ui.Model, *os.File) { var loggerFile *os.File if debug { @@ -63,7 +65,7 @@ func createModel(configPath string, debug bool) (ui.Model, *os.File) { } } - return ui.NewModel(configPath), loggerFile + return ui.NewModel(repoPath, configPath), loggerFile } func buildVersion(version, commit, date, builtBy string) string { @@ -113,7 +115,12 @@ func init() { "help for gh-dash", ) - rootCmd.Run = func(_ *cobra.Command, _ []string) { + rootCmd.Run = func(_ *cobra.Command, args []string) { + var repo *string + repos := config.IsFeatureEnabled(config.FF_REPO_VIEW) + if repos && len(args) > 0 { + repo = &args[0] + } debug, err := rootCmd.Flags().GetBool("debug") if err != nil { log.Fatal("Cannot parse debug flag", err) @@ -123,7 +130,7 @@ func init() { lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) markdown.InitializeMarkdownStyle(termenv.HasDarkBackground()) - model, logger := createModel(cfgFile, debug) + model, logger := createModel(repo, cfgFile, debug) if logger != nil { defer logger.Close() } diff --git a/config/feature_flags.go b/config/feature_flags.go new file mode 100644 index 00000000..949e034d --- /dev/null +++ b/config/feature_flags.go @@ -0,0 +1,10 @@ +package config + +import "os" + +const FF_REPO_VIEW = "FF_REPO_VIEW" + +func IsFeatureEnabled(name string) bool { + _, ok := os.LookupEnv(name) + return ok +} diff --git a/config/parser.go b/config/parser.go index 2828c401..9d377132 100644 --- a/config/parser.go +++ b/config/parser.go @@ -31,12 +31,14 @@ type ViewType string const ( PRsView ViewType = "prs" IssuesView ViewType = "issues" + RepoView ViewType = "repo" ) type SectionConfig struct { Title string Filters string Limit *int `yaml:"limit,omitempty"` + Type *ViewType } type PrsSectionConfig struct { @@ -44,6 +46,7 @@ type PrsSectionConfig struct { Filters string Limit *int `yaml:"limit,omitempty"` Layout PrsLayoutConfig `yaml:"layout,omitempty"` + Type *ViewType } type IssuesSectionConfig struct { @@ -226,7 +229,7 @@ func (parser ConfigParser) getDefaultConfig() Config { Hidden: utils.BoolPtr(true), }, Lines: ColumnConfig{ - Width: utils.IntPtr(lipgloss.Width("123450 / -123450")), + Width: utils.IntPtr(lipgloss.Width(" +31.4k -31.6k ")), }, }, Issues: IssuesLayoutConfig{ @@ -408,6 +411,10 @@ func (parser ConfigParser) readConfigFile(path string) (Config, error) { if err != nil { return config, err } + repoFF := IsFeatureEnabled(FF_REPO_VIEW) + if config.Defaults.View == RepoView && !repoFF { + config.Defaults.View = PRsView + } err = validate.Struct(config) return config, err diff --git a/config/utils.go b/config/utils.go index 0c72e03a..65a6b448 100644 --- a/config/utils.go +++ b/config/utils.go @@ -32,6 +32,7 @@ func (cfg PrsSectionConfig) ToSectionConfig() SectionConfig { Title: cfg.Title, Filters: cfg.Filters, Limit: cfg.Limit, + Type: cfg.Type, } } diff --git a/git/git.go b/git/git.go new file mode 100644 index 00000000..fab2c506 --- /dev/null +++ b/git/git.go @@ -0,0 +1,60 @@ +package git + +import ( + "sort" + "time" + + gitm "github.com/aymanbagabas/git-module" +) + +// Extends git.Repository +type Repo struct { + Origin string + Remotes []string + Branches []Branch +} + +type Branch struct { + Name string + LastUpdatedAt *time.Time +} + +func GetRepo(dir string) (*Repo, error) { + repo, err := gitm.Open(dir) + if err != nil { + return nil, err + } + + bNames, err := repo.Branches() + if err != nil { + return nil, err + } + + branches := make([]Branch, len(bNames)) + for i, b := range bNames { + var updatedAt *time.Time + commits, err := gitm.Log(dir, b, gitm.LogOptions{MaxCount: 1}) + if err == nil && len(commits) > 0 { + updatedAt = &commits[0].Committer.When + } + branches[i] = Branch{Name: b, LastUpdatedAt: updatedAt} + } + sort.Slice(branches, func(i, j int) bool { + if branches[j].LastUpdatedAt == nil || branches[i].LastUpdatedAt == nil { + return false + } + return branches[i].LastUpdatedAt.After(*branches[j].LastUpdatedAt) + }) + + remotes, err := repo.Remotes() + if err != nil { + return nil, err + } + + origin, err := gitm.RemoteGetURL(dir, "origin", gitm.RemoteGetURLOptions{All: true}) + if err != nil { + return nil, err + } + + return &Repo{Origin: origin[0], Remotes: remotes, Branches: branches}, nil +} diff --git a/go.mod b/go.mod index c1d130f0..507ec956 100644 --- a/go.mod +++ b/go.mod @@ -18,12 +18,18 @@ require ( github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc github.com/spf13/cobra v1.8.0 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v2 v2.4.0 ) +require ( + github.com/kr/text v0.2.0 // indirect + github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect +) + require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymanbagabas/git-module v1.8.4-0.20231101154130-8d27204ac6d2 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect @@ -42,7 +48,6 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/henvic/httpretty v0.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 28d0d706..fe667f05 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/git-module v1.8.4-0.20231101154130-8d27204ac6d2 h1:3w5KT+shE3hzWhORGiu2liVjEoaCEXm9uZP47+Gw4So= +github.com/aymanbagabas/git-module v1.8.4-0.20231101154130-8d27204ac6d2/go.mod h1:d4gQ7/3/S2sPq4NnKdtAgUOVr6XtLpWFtxyVV5/+76U= 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -38,6 +40,7 @@ github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -89,6 +92,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk= +github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -116,8 +121,14 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= @@ -135,6 +146,7 @@ golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -159,5 +171,6 @@ gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ui/components/footer/footer.go b/ui/components/footer/footer.go index 03a58fab..c444c318 100644 --- a/ui/components/footer/footer.go +++ b/ui/components/footer/footer.go @@ -118,8 +118,10 @@ func (m *Model) renderViewSwitcher(ctx context.ProgramContext) string { var view string if ctx.View == config.PRsView { view += " PRs" - } else { + } else if ctx.View == config.IssuesView { view += " Issues" + } else if ctx.View == config.RepoView { + view += " Repo" } var user string diff --git a/ui/components/issue/issue.go b/ui/components/issue/issue.go index 248bd8f3..4a05224f 100644 --- a/ui/components/issue/issue.go +++ b/ui/components/issue/issue.go @@ -32,7 +32,7 @@ func (issue *Issue) ToTableRow() table.Row { } func (issue *Issue) getTextStyle() lipgloss.Style { - return components.GetIssueTextStyle(issue.Ctx, issue.Data.State) + return components.GetIssueTextStyle(issue.Ctx) } func (issue *Issue) renderUpdateAt() string { diff --git a/ui/components/pr/pr.go b/ui/components/pr/pr.go index 60953937..349b485a 100644 --- a/ui/components/pr/pr.go +++ b/ui/components/pr/pr.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/dlvhdr/gh-dash/v4/data" + "github.com/dlvhdr/gh-dash/v4/git" "github.com/dlvhdr/gh-dash/v4/ui/components" "github.com/dlvhdr/gh-dash/v4/ui/components/table" "github.com/dlvhdr/gh-dash/v4/ui/constants" @@ -15,15 +16,20 @@ import ( ) type PullRequest struct { - Ctx *context.ProgramContext - Data data.PullRequestData + Ctx *context.ProgramContext + Data *data.PullRequestData + Branch git.Branch + Columns []table.Column } func (pr *PullRequest) getTextStyle() lipgloss.Style { - return components.GetIssueTextStyle(pr.Ctx, pr.Data.State) + return components.GetIssueTextStyle(pr.Ctx) } func (pr *PullRequest) renderReviewStatus() string { + if pr.Data == nil { + return "-" + } reviewCellStyle := pr.getTextStyle() if pr.Data.ReviewDecision == "APPROVED" { reviewCellStyle = reviewCellStyle.Foreground( @@ -44,6 +50,11 @@ func (pr *PullRequest) renderReviewStatus() string { func (pr *PullRequest) renderState() string { mergeCellStyle := lipgloss.NewStyle() + + if pr.Data == nil { + return mergeCellStyle.Foreground(pr.Ctx.Theme.SuccessText).Render("󰜛") + } + switch pr.Data.State { case "OPEN": if pr.Data.IsDraft { @@ -99,6 +110,9 @@ func (pr *PullRequest) GetStatusChecksRollup() string { } func (pr *PullRequest) renderCiStatus() string { + if pr.Data == nil { + return "-" + } accStatus := pr.GetStatusChecksRollup() ciCellStyle := pr.getTextStyle() @@ -116,6 +130,9 @@ func (pr *PullRequest) renderCiStatus() string { } func (pr *PullRequest) renderLines(isSelected bool) string { + if pr.Data == nil { + return "-" + } deletions := 0 if pr.Data.Deletions > 0 { deletions = pr.Data.Deletions @@ -173,23 +190,52 @@ func (pr *PullRequest) renderExtendedTitle(isSelected bool) string { if isSelected { baseStyle = baseStyle.Foreground(pr.Ctx.Theme.SecondaryText).Background(pr.Ctx.Theme.SelectedBackground) } - repoName := baseStyle.Render(pr.Data.Repository.NameWithOwner) - prNumber := baseStyle.Render(fmt.Sprintf("#%d", pr.Data.Number)) + + if pr.Data == nil { + return pr.renderBranch(isSelected) + } + repoName := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, pr.Data.Repository.NameWithOwner, fmt.Sprintf(" #%d", pr.Data.Number))) author := baseStyle.Render(fmt.Sprintf("@%s", pr.Data.Author.Login)) - top := lipgloss.JoinHorizontal(lipgloss.Top, repoName, baseStyle.Render(" · "), prNumber, baseStyle.Render(" · "), author) + branch := baseStyle.Render(pr.Data.HeadRefName) + top := lipgloss.JoinHorizontal(lipgloss.Top, repoName, baseStyle.Render(" · "), branch, baseStyle.Render(" · "), author) title := pr.Data.Title - width := max(lipgloss.Width(top), lipgloss.Width(title)) - top = baseStyle.Foreground(pr.Ctx.Theme.SecondaryText).Width(width).Render(top) - title = baseStyle.Foreground(pr.Ctx.Theme.PrimaryText).Width(width).Render(title) + var titleColumn table.Column + for _, column := range pr.Columns { + if column.Title == "Title" { + titleColumn = column + } + } + width := titleColumn.ComputedWidth - 2 + top = baseStyle.Copy().Foreground(pr.Ctx.Theme.SecondaryText).Width(width).MaxWidth(width).Height(1).MaxHeight(1).Render(top) + title = baseStyle.Copy().Foreground(pr.Ctx.Theme.PrimaryText).Width(width).MaxWidth(width).Render(title) return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, top, title)) } +func (pr *PullRequest) renderBranch(isSelected bool) string { + baseStyle := lipgloss.NewStyle() + if isSelected { + baseStyle = baseStyle.Foreground(pr.Ctx.Theme.SecondaryText).Background(pr.Ctx.Theme.SelectedBackground) + } + + // repoName := strings.TrimPrefix(pr.Ctx.Repo.Origin, "https://github.com/") + // repoName = strings.TrimSuffix(repoName, ".git") + top := lipgloss.JoinHorizontal(lipgloss.Top, baseStyle.Render("TODO"), baseStyle.Render(" · "), baseStyle.Render("UNPUBLISHED")) + branch := pr.Branch.Name + width := max(lipgloss.Width(top), lipgloss.Width(branch)) + top = baseStyle.Foreground(pr.Ctx.Theme.SecondaryText).Width(width).Render(top) + title := baseStyle.Foreground(pr.Ctx.Theme.PrimaryText).Width(width).Render(branch) + return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, top, title)) +} + func (pr *PullRequest) renderAuthor() string { return pr.getTextStyle().Render(pr.Data.Author.Login) } func (pr *PullRequest) renderAssignees() string { + if pr.Data == nil { + return "" + } assignees := make([]string, 0, len(pr.Data.Assignees.Nodes)) for _, assignee := range pr.Data.Assignees.Nodes { assignees = append(assignees, assignee.Login) @@ -211,16 +257,28 @@ func (pr *PullRequest) renderUpdateAt() string { timeFormat := pr.Ctx.Config.Defaults.DateFormat updatedAtOutput := "" + t := pr.Branch.LastUpdatedAt + if pr.Data != nil { + t = &pr.Data.UpdatedAt + } + + if t == nil { + return "" + } + if timeFormat == "" || timeFormat == "relative" { - updatedAtOutput = utils.TimeElapsed(pr.Data.UpdatedAt) + updatedAtOutput = utils.TimeElapsed(*t) } else { - updatedAtOutput = pr.Data.UpdatedAt.Format(timeFormat) + updatedAtOutput = t.Format(timeFormat) } return pr.getTextStyle().Copy().Foreground(pr.Ctx.Theme.FaintText).Render(updatedAtOutput) } func (pr *PullRequest) renderBaseName() string { + if pr.Data == nil { + return "" + } return pr.getTextStyle().Render(pr.Data.BaseRefName) } diff --git a/ui/components/prsidebar/approve.go b/ui/components/prsidebar/approve.go index b7158797..ddc66ef6 100644 --- a/ui/components/prsidebar/approve.go +++ b/ui/components/prsidebar/approve.go @@ -5,7 +5,9 @@ import ( "os/exec" tea "github.com/charmbracelet/bubbletea" + "github.com/dlvhdr/gh-dash/v4/ui/components/prssection" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" ) @@ -44,7 +46,7 @@ func (m *Model) approve(comment string) tea.Cmd { SectionType: prssection.SectionType, TaskId: taskId, Err: err, - Msg: prssection.UpdatePRMsg{ + Msg: tasks.UpdatePRMsg{ PrNumber: prNumber, }, } diff --git a/ui/components/prsidebar/assign.go b/ui/components/prsidebar/assign.go index 27cad53d..c7dfd1f8 100644 --- a/ui/components/prsidebar/assign.go +++ b/ui/components/prsidebar/assign.go @@ -5,8 +5,10 @@ import ( "os/exec" tea "github.com/charmbracelet/bubbletea" + "github.com/dlvhdr/gh-dash/v4/data" "github.com/dlvhdr/gh-dash/v4/ui/components/prssection" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" ) @@ -49,7 +51,7 @@ func (m *Model) assign(usernames []string) tea.Cmd { SectionType: prssection.SectionType, TaskId: taskId, Err: err, - Msg: prssection.UpdatePRMsg{ + Msg: tasks.UpdatePRMsg{ PrNumber: prNumber, AddedAssignees: &returnedAssignees, }, diff --git a/ui/components/prsidebar/comment.go b/ui/components/prsidebar/comment.go index c1b27684..0bdbb989 100644 --- a/ui/components/prsidebar/comment.go +++ b/ui/components/prsidebar/comment.go @@ -6,8 +6,10 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/dlvhdr/gh-dash/v4/data" "github.com/dlvhdr/gh-dash/v4/ui/components/prssection" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" ) @@ -42,7 +44,7 @@ func (m *Model) comment(body string) tea.Cmd { SectionType: prssection.SectionType, TaskId: taskId, Err: err, - Msg: prssection.UpdatePRMsg{ + Msg: tasks.UpdatePRMsg{ PrNumber: prNumber, NewComment: &data.Comment{ Author: struct{ Login string }{Login: m.ctx.User}, diff --git a/ui/components/prsidebar/prsidebar.go b/ui/components/prsidebar/prsidebar.go index 8a21af70..1c942004 100644 --- a/ui/components/prsidebar/prsidebar.go +++ b/ui/components/prsidebar/prsidebar.go @@ -299,7 +299,7 @@ func (m *Model) SetRow(data *data.PullRequestData) { if data == nil { m.pr = nil } else { - m.pr = &pr.PullRequest{Ctx: m.ctx, Data: *data} + m.pr = &pr.PullRequest{Ctx: m.ctx, Data: data} } } diff --git a/ui/components/prsidebar/unassign.go b/ui/components/prsidebar/unassign.go index 75c7b140..a8fb8ae2 100644 --- a/ui/components/prsidebar/unassign.go +++ b/ui/components/prsidebar/unassign.go @@ -5,8 +5,10 @@ import ( "os/exec" tea "github.com/charmbracelet/bubbletea" + "github.com/dlvhdr/gh-dash/v4/data" "github.com/dlvhdr/gh-dash/v4/ui/components/prssection" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" ) @@ -49,7 +51,7 @@ func (m *Model) unassign(usernames []string) tea.Cmd { SectionType: prssection.SectionType, TaskId: taskId, Err: err, - Msg: prssection.UpdatePRMsg{ + Msg: tasks.UpdatePRMsg{ PrNumber: prNumber, RemovedAssignees: &returnedAssignees, }, diff --git a/ui/components/prssection/close.go b/ui/components/prssection/close.go deleted file mode 100644 index 84741e8f..00000000 --- a/ui/components/prssection/close.go +++ /dev/null @@ -1,47 +0,0 @@ -package prssection - -import ( - "fmt" - "os/exec" - - tea "github.com/charmbracelet/bubbletea" - "github.com/dlvhdr/gh-dash/v4/ui/constants" - "github.com/dlvhdr/gh-dash/v4/ui/context" - "github.com/dlvhdr/gh-dash/v4/utils" -) - -func (m *Model) close() tea.Cmd { - pr := m.GetCurrRow() - prNumber := pr.GetNumber() - taskId := fmt.Sprintf("pr_close_%d", prNumber) - task := context.Task{ - Id: taskId, - StartText: fmt.Sprintf("Closing PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been closed", prNumber), - State: context.TaskStart, - Error: nil, - } - startCmd := m.Ctx.StartTask(task) - return tea.Batch(startCmd, func() tea.Msg { - c := exec.Command( - "gh", - "pr", - "close", - fmt.Sprint(m.GetCurrRow().GetNumber()), - "-R", - m.GetCurrRow().GetRepoNameWithOwner(), - ) - - err := c.Run() - return constants.TaskFinishedMsg{ - SectionId: m.Id, - SectionType: SectionType, - TaskId: taskId, - Err: err, - Msg: UpdatePRMsg{ - PrNumber: prNumber, - IsClosed: utils.BoolPtr(true), - }, - } - }) -} diff --git a/ui/components/prssection/merge.go b/ui/components/prssection/merge.go deleted file mode 100644 index 0e550331..00000000 --- a/ui/components/prssection/merge.go +++ /dev/null @@ -1,50 +0,0 @@ -package prssection - -import ( - "fmt" - "os/exec" - - tea "github.com/charmbracelet/bubbletea" - "github.com/dlvhdr/gh-dash/v4/ui/constants" - "github.com/dlvhdr/gh-dash/v4/ui/context" -) - -func (m Model) merge() tea.Cmd { - prNumber := m.GetCurrRow().GetNumber() - c := exec.Command( - "gh", - "pr", - "merge", - fmt.Sprint(prNumber), - "-R", - m.GetCurrRow().GetRepoNameWithOwner(), - ) - - taskId := fmt.Sprintf("merge_%d", prNumber) - task := context.Task{ - Id: taskId, - StartText: fmt.Sprintf("Merging PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been merged", prNumber), - State: context.TaskStart, - Error: nil, - } - startCmd := m.Ctx.StartTask(task) - - return tea.Batch(startCmd, tea.ExecProcess(c, func(err error) tea.Msg { - isMerged := false - if err == nil && c.ProcessState.ExitCode() == 0 { - isMerged = true - } - - return constants.TaskFinishedMsg{ - SectionId: m.Id, - SectionType: SectionType, - TaskId: taskId, - Err: err, - Msg: UpdatePRMsg{ - PrNumber: prNumber, - IsMerged: &isMerged, - }, - } - })) -} diff --git a/ui/components/prssection/prssection.go b/ui/components/prssection/prssection.go index ebf6c9ac..9059f848 100644 --- a/ui/components/prssection/prssection.go +++ b/ui/components/prssection/prssection.go @@ -12,6 +12,7 @@ import ( "github.com/dlvhdr/gh-dash/v4/ui/components/pr" "github.com/dlvhdr/gh-dash/v4/ui/components/section" "github.com/dlvhdr/gh-dash/v4/ui/components/table" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" "github.com/dlvhdr/gh-dash/v4/ui/keys" @@ -84,16 +85,18 @@ func (m Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { case msg.Type == tea.KeyEnter: input := m.PromptConfirmationBox.Value() action := m.GetPromptConfirmationAction() + pr := m.GetCurrRow() + sid := tasks.SectionIdentifer{Id: m.Id, Type: SectionType} if input == "Y" || input == "y" { switch action { case "close": - cmd = m.close() + cmd = tasks.ClosePR(m.Ctx, sid, pr) case "reopen": - cmd = m.reopen() + cmd = tasks.ReopenPR(m.Ctx, sid, pr) case "ready": - cmd = m.ready() + cmd = tasks.PRReady(m.Ctx, sid, pr) case "merge": - cmd = m.merge() + cmd = tasks.MergePR(m.Ctx, sid, pr) } } @@ -122,7 +125,7 @@ func (m Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { } - case UpdatePRMsg: + case tasks.UpdatePRMsg: for i, currPr := range m.Prs { if currPr.Number == msg.PrNumber { if msg.IsClosed != nil { @@ -317,7 +320,7 @@ func (m Model) BuildRows() []table.Row { currItem := m.Table.GetCurrItem() for i, currPr := range m.Prs { i := i - prModel := pr.PullRequest{Ctx: m.Ctx, Data: currPr} + prModel := pr.PullRequest{Ctx: m.Ctx, Data: &currPr, Columns: m.Table.Columns} rows = append( rows, prModel.ToTableRow(currItem == i), @@ -443,16 +446,6 @@ func FetchAllSections( return sections, tea.Batch(fetchPRsCmds...) } -type UpdatePRMsg struct { - PrNumber int - IsClosed *bool - NewComment *data.Comment - ReadyForReview *bool - IsMerged *bool - AddedAssignees *data.Assignees - RemovedAssignees *data.Assignees -} - func addAssignees(assignees, addedAssignees []data.Assignee) []data.Assignee { newAssignees := assignees for _, assignee := range addedAssignees { diff --git a/ui/components/prssection/ready.go b/ui/components/prssection/ready.go deleted file mode 100644 index 5e04f69e..00000000 --- a/ui/components/prssection/ready.go +++ /dev/null @@ -1,47 +0,0 @@ -package prssection - -import ( - "fmt" - "os/exec" - - tea "github.com/charmbracelet/bubbletea" - "github.com/dlvhdr/gh-dash/v4/ui/constants" - "github.com/dlvhdr/gh-dash/v4/ui/context" - "github.com/dlvhdr/gh-dash/v4/utils" -) - -func (m *Model) ready() tea.Cmd { - pr := m.GetCurrRow() - prNumber := pr.GetNumber() - taskId := fmt.Sprintf("ready_%d", prNumber) - task := context.Task{ - Id: taskId, - StartText: fmt.Sprintf("Marking PR #%d as ready for review", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been marked as ready for review", prNumber), - State: context.TaskStart, - Error: nil, - } - startCmd := m.Ctx.StartTask(task) - return tea.Batch(startCmd, func() tea.Msg { - c := exec.Command( - "gh", - "pr", - "ready", - fmt.Sprint(m.GetCurrRow().GetNumber()), - "-R", - m.GetCurrRow().GetRepoNameWithOwner(), - ) - - err := c.Run() - return constants.TaskFinishedMsg{ - SectionId: m.Id, - SectionType: SectionType, - TaskId: taskId, - Err: err, - Msg: UpdatePRMsg{ - PrNumber: prNumber, - ReadyForReview: utils.BoolPtr(true), - }, - } - }) -} diff --git a/ui/components/prssection/reopen.go b/ui/components/prssection/reopen.go deleted file mode 100644 index 2ba2611c..00000000 --- a/ui/components/prssection/reopen.go +++ /dev/null @@ -1,47 +0,0 @@ -package prssection - -import ( - "fmt" - "os/exec" - - tea "github.com/charmbracelet/bubbletea" - "github.com/dlvhdr/gh-dash/v4/ui/constants" - "github.com/dlvhdr/gh-dash/v4/ui/context" - "github.com/dlvhdr/gh-dash/v4/utils" -) - -func (m *Model) reopen() tea.Cmd { - pr := m.GetCurrRow() - prNumber := pr.GetNumber() - taskId := fmt.Sprintf("pr_reopen_%d", prNumber) - task := context.Task{ - Id: taskId, - StartText: fmt.Sprintf("Reopening PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been reopened", prNumber), - State: context.TaskStart, - Error: nil, - } - startCmd := m.Ctx.StartTask(task) - return tea.Batch(startCmd, func() tea.Msg { - c := exec.Command( - "gh", - "pr", - "reopen", - fmt.Sprint(m.GetCurrRow().GetNumber()), - "-R", - m.GetCurrRow().GetRepoNameWithOwner(), - ) - - err := c.Run() - return constants.TaskFinishedMsg{ - SectionId: m.Id, - SectionType: SectionType, - TaskId: taskId, - Err: err, - Msg: UpdatePRMsg{ - PrNumber: prNumber, - IsClosed: utils.BoolPtr(false), - }, - } - }) -} diff --git a/ui/components/prssection/watchChecks.go b/ui/components/prssection/watchChecks.go index f697ebfb..6b0c8b09 100644 --- a/ui/components/prssection/watchChecks.go +++ b/ui/components/prssection/watchChecks.go @@ -11,6 +11,7 @@ import ( "github.com/dlvhdr/gh-dash/v4/data" prComponent "github.com/dlvhdr/gh-dash/v4/ui/components/pr" + "github.com/dlvhdr/gh-dash/v4/ui/components/tasks" "github.com/dlvhdr/gh-dash/v4/ui/constants" "github.com/dlvhdr/gh-dash/v4/ui/context" ) @@ -59,7 +60,7 @@ func (m *Model) watchChecks() tea.Cmd { log.Debug("Error fetching updated PR details", "url", url, "err", err) } - renderedPr := prComponent.PullRequest{Ctx: m.Ctx, Data: updatedPr} + renderedPr := prComponent.PullRequest{Ctx: m.Ctx, Data: &updatedPr} checksRollup := " Checks are pending" switch renderedPr.GetStatusChecksRollup() { case "SUCCESS": @@ -83,7 +84,7 @@ func (m *Model) watchChecks() tea.Cmd { SectionType: SectionType, TaskId: taskId, Err: err, - Msg: UpdatePRMsg{ + Msg: tasks.UpdatePRMsg{ PrNumber: prNumber, }, } diff --git a/ui/components/reposection/reposection.go b/ui/components/reposection/reposection.go new file mode 100644 index 00000000..834e7660 --- /dev/null +++ b/ui/components/reposection/reposection.go @@ -0,0 +1,517 @@ +package reposection + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/dlvhdr/gh-dash/v4/config" + "github.com/dlvhdr/gh-dash/v4/data" + "github.com/dlvhdr/gh-dash/v4/git" + "github.com/dlvhdr/gh-dash/v4/ui/components/pr" + "github.com/dlvhdr/gh-dash/v4/ui/components/section" + "github.com/dlvhdr/gh-dash/v4/ui/components/table" + "github.com/dlvhdr/gh-dash/v4/ui/constants" + "github.com/dlvhdr/gh-dash/v4/ui/context" + "github.com/dlvhdr/gh-dash/v4/utils" +) + +const SectionType = "repo" + +type Model struct { + section.BaseModel + repo *git.Repo + Prs []data.PullRequestData +} + +func NewModel( + id int, + ctx *context.ProgramContext, + cfg config.PrsSectionConfig, + lastUpdated time.Time, +) Model { + m := Model{} + m.BaseModel = section.NewModel( + id, + ctx, + cfg.ToSectionConfig(), + SectionType, + GetSectionColumns(cfg, ctx), + m.GetItemSingularForm(), + m.GetItemPluralForm(), + lastUpdated, + ) + m.repo = &git.Repo{Branches: []git.Branch{}} + m.Prs = []data.PullRequestData{} + + return m +} + +func (m Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + + case tea.KeyMsg: + + if m.IsSearchFocused() { + switch { + + case msg.Type == tea.KeyCtrlC, msg.Type == tea.KeyEsc: + m.SearchBar.SetValue(m.SearchValue) + blinkCmd := m.SetIsSearching(false) + return &m, blinkCmd + + case msg.Type == tea.KeyEnter: + m.SearchValue = m.SearchBar.Value() + m.SetIsSearching(false) + m.ResetRows() + return &m, tea.Batch(m.FetchNextPageSectionRows()...) + } + + break + } + + case UpdatePRMsg: + for i, currPr := range m.Prs { + if currPr.Number == msg.PrNumber { + if msg.IsClosed != nil { + if *msg.IsClosed { + currPr.State = "CLOSED" + } else { + currPr.State = "OPEN" + } + } + if msg.NewComment != nil { + currPr.Comments.Nodes = append(currPr.Comments.Nodes, *msg.NewComment) + } + if msg.AddedAssignees != nil { + currPr.Assignees.Nodes = addAssignees(currPr.Assignees.Nodes, msg.AddedAssignees.Nodes) + } + if msg.RemovedAssignees != nil { + currPr.Assignees.Nodes = removeAssignees(currPr.Assignees.Nodes, msg.RemovedAssignees.Nodes) + } + if msg.ReadyForReview != nil && *msg.ReadyForReview { + currPr.IsDraft = false + } + if msg.IsMerged != nil && *msg.IsMerged { + currPr.State = "MERGED" + currPr.Mergeable = "" + } + m.Prs[i] = currPr + m.Table.SetIsLoading(false) + m.Table.SetRows(m.BuildRows()) + break + } + } + + case repoMsg: + m.repo = msg.repo + m.Table.SetIsLoading(false) + m.Table.SetRows(m.BuildRows()) + + case SectionPullRequestsFetchedMsg: + if m.LastFetchTaskId == msg.TaskId { + m.Prs = msg.Prs + m.TotalCount = msg.TotalCount + m.PageInfo = &msg.PageInfo + m.Table.SetIsLoading(false) + m.Table.SetRows(m.BuildRows()) + m.Table.UpdateLastUpdated(time.Now()) + m.UpdateTotalItemsCount(m.TotalCount) + } + } + + search, searchCmd := m.SearchBar.Update(msg) + m.Table.SetRows(m.BuildRows()) + m.SearchBar = search + + prompt, promptCmd := m.PromptConfirmationBox.Update(msg) + m.PromptConfirmationBox = prompt + + table, tableCmd := m.Table.Update(msg) + m.Table = table + + return &m, tea.Batch(cmd, searchCmd, promptCmd, tableCmd) +} + +func GetSectionColumns( + cfg config.PrsSectionConfig, + ctx *context.ProgramContext, +) []table.Column { + dLayout := ctx.Config.Defaults.Layout.Prs + sLayout := cfg.Layout + + updatedAtLayout := config.MergeColumnConfigs( + dLayout.UpdatedAt, + sLayout.UpdatedAt, + ) + repoLayout := config.MergeColumnConfigs(dLayout.Repo, sLayout.Repo) + titleLayout := config.MergeColumnConfigs(dLayout.Title, sLayout.Title) + authorLayout := config.MergeColumnConfigs(dLayout.Author, sLayout.Author) + assigneesLayout := config.MergeColumnConfigs( + dLayout.Assignees, + sLayout.Assignees, + ) + baseLayout := config.MergeColumnConfigs(dLayout.Base, sLayout.Base) + reviewStatusLayout := config.MergeColumnConfigs( + dLayout.ReviewStatus, + sLayout.ReviewStatus, + ) + stateLayout := config.MergeColumnConfigs(dLayout.State, sLayout.State) + ciLayout := config.MergeColumnConfigs(dLayout.Ci, sLayout.Ci) + linesLayout := config.MergeColumnConfigs(dLayout.Lines, sLayout.Lines) + + if !ctx.Config.Theme.Ui.Table.Compact { + return []table.Column{ + { + Title: "", + Width: utils.IntPtr(3), + Hidden: stateLayout.Hidden, + }, + { + Title: "Title", + Grow: utils.BoolPtr(true), + Hidden: titleLayout.Hidden, + }, + { + Title: "Assignees", + Width: assigneesLayout.Width, + Hidden: assigneesLayout.Hidden, + }, + { + Title: "Base", + Width: baseLayout.Width, + Hidden: baseLayout.Hidden, + }, + { + Title: "󰯢", + Width: utils.IntPtr(4), + Hidden: reviewStatusLayout.Hidden, + }, + { + Title: "", + Width: &ctx.Styles.PrSection.CiCellWidth, + Grow: new(bool), + Hidden: ciLayout.Hidden, + }, + { + Title: "", + Width: linesLayout.Width, + Hidden: linesLayout.Hidden, + }, + { + Title: "", + Width: updatedAtLayout.Width, + Hidden: updatedAtLayout.Hidden, + }, + } + } + + return []table.Column{ + { + Title: "", + Width: utils.IntPtr(3), + Hidden: stateLayout.Hidden, + }, + { + Title: "", + Width: repoLayout.Width, + Hidden: repoLayout.Hidden, + }, + { + Title: "Title", + Grow: utils.BoolPtr(true), + Hidden: titleLayout.Hidden, + }, + { + Title: "Author", + Width: authorLayout.Width, + Hidden: authorLayout.Hidden, + }, + { + Title: "Assignees", + Width: assigneesLayout.Width, + Hidden: assigneesLayout.Hidden, + }, + { + Title: "Base", + Width: baseLayout.Width, + Hidden: baseLayout.Hidden, + }, + { + Title: "󰯢", + Width: utils.IntPtr(4), + Hidden: reviewStatusLayout.Hidden, + }, + { + Title: "", + Width: &ctx.Styles.PrSection.CiCellWidth, + Grow: new(bool), + Hidden: ciLayout.Hidden, + }, + { + Title: "", + Width: linesLayout.Width, + Hidden: linesLayout.Hidden, + }, + { + Title: "", + Width: updatedAtLayout.Width, + Hidden: updatedAtLayout.Hidden, + }, + } +} + +func (m Model) BuildRows() []table.Row { + var rows []table.Row + currItem := m.Table.GetCurrItem() + + for i, ref := range m.repo.Branches { + i := i + prModel := pr.PullRequest{Ctx: m.Ctx, Branch: ref, Columns: m.Table.Columns} + prModel.Data = findPRForRef(m.Prs, ref.Name) + rows = append( + rows, + prModel.ToTableRow(currItem == i), + ) + } + + if rows == nil { + rows = []table.Row{} + } + + return rows +} + +func findPRForRef(prs []data.PullRequestData, branch string) *data.PullRequestData { + for _, pr := range prs { + if pr.HeadRefName == branch { + return &pr + } + } + return nil +} + +func (m *Model) NumRows() int { + return len(m.repo.Branches) +} + +type SectionPullRequestsFetchedMsg struct { + Prs []data.PullRequestData + TotalCount int + PageInfo data.PageInfo + TaskId string +} + +func (m *Model) GetCurrRow() data.RowData { + if len(m.repo.Branches) == 0 { + return nil + } + branch := m.repo.Branches[m.Table.GetCurrItem()] + pr := findPRForRef(m.Prs, branch.Name) + return pr +} + +func (m *Model) FetchNextPageSectionRows() []tea.Cmd { + if m == nil { + return nil + } + + if m.PageInfo != nil && !m.PageInfo.HasNextPage { + return nil + } + + var cmds []tea.Cmd + + startCursor := time.Now().String() + if m.PageInfo != nil { + startCursor = m.PageInfo.StartCursor + } + taskId := fmt.Sprintf("fetching_prs_%d_%s", m.Id, startCursor) + m.LastFetchTaskId = taskId + + branchesTaskId := fmt.Sprintf("fetching_branches_%d", time.Now().Unix()) + if m.Ctx.RepoPath != nil { + branchesTask := context.Task{ + Id: branchesTaskId, + StartText: "Reading local branches", + FinishedText: fmt.Sprintf( + `Read branches successfully for "%s"`, + *m.Ctx.RepoPath, + ), + State: context.TaskStart, + Error: nil, + } + bCmd := m.Ctx.StartTask(branchesTask) + cmds = append(cmds, bCmd) + } + + task := context.Task{ + Id: taskId, + StartText: "Fetching PRs for your branches", + FinishedText: "PRs for your branches have been fetched", + State: context.TaskStart, + Error: nil, + } + startCmd := m.Ctx.StartTask(task) + cmds = append(cmds, startCmd) + + var repoCmd tea.Cmd + if m.Ctx.RepoPath != nil { + repoCmd = func() tea.Msg { + repo, err := git.GetRepo(*m.Ctx.RepoPath) + return constants.TaskFinishedMsg{ + SectionId: m.Id, + SectionType: m.Type, + TaskId: branchesTaskId, + Msg: repoMsg{repo: repo}, + Err: err, + } + } + } + fetchCmd := func() tea.Msg { + limit := m.Config.Limit + if limit == nil { + limit = &m.Ctx.Config.Defaults.PrsLimit + } + res, err := data.FetchPullRequests(m.GetFilters(), *limit, m.PageInfo) + // TODO: enrich with branches only for section with branches + if err != nil { + return constants.TaskFinishedMsg{ + SectionId: m.Id, + SectionType: m.Type, + TaskId: taskId, + Err: err, + } + } + + return constants.TaskFinishedMsg{ + SectionId: m.Id, + SectionType: m.Type, + TaskId: taskId, + Msg: SectionPullRequestsFetchedMsg{ + Prs: res.Prs, + TotalCount: res.TotalCount, + PageInfo: res.PageInfo, + TaskId: taskId, + }, + } + } + cmds = append(cmds, fetchCmd, repoCmd) + + if m.PageInfo == nil { + m.Table.SetIsLoading(true) + cmds = append(cmds, m.Table.StartLoadingSpinner()) + + } + + return cmds +} + +func (m *Model) ResetRows() { + m.Prs = nil + m.BaseModel.ResetRows() +} + +type repoMsg struct { + repo *git.Repo + err error +} + +func openRepoCmd(dir string) tea.Cmd { + return func() tea.Msg { + repo, err := git.GetRepo(dir) + return repoMsg{repo: repo, err: err} + } +} + +func FetchAllBranches( + ctx context.ProgramContext, +) (sections []section.Section, fetchAllCmd tea.Cmd) { + + cmds := make([]tea.Cmd, 0) + if ctx.RepoPath != nil { + cmds = append(cmds, openRepoCmd(*ctx.RepoPath)) + } + + t := config.RepoView + cfg := config.PrsSectionConfig{ + Title: "Local Branches", + Filters: "author:@me", + Limit: utils.IntPtr(20), + Type: &t, + } + s := NewModel( + 1, + &ctx, + cfg, + time.Now(), + ) + cmds = append(cmds, s.FetchNextPageSectionRows()...) + + return []section.Section{&s}, tea.Batch(cmds...) +} + +type UpdatePRMsg struct { + PrNumber int + IsClosed *bool + NewComment *data.Comment + ReadyForReview *bool + IsMerged *bool + AddedAssignees *data.Assignees + RemovedAssignees *data.Assignees +} + +func addAssignees(assignees, addedAssignees []data.Assignee) []data.Assignee { + newAssignees := assignees + for _, assignee := range addedAssignees { + if !assigneesContains(newAssignees, assignee) { + newAssignees = append(newAssignees, assignee) + } + } + + return newAssignees +} + +func removeAssignees( + assignees, removedAssignees []data.Assignee, +) []data.Assignee { + newAssignees := []data.Assignee{} + for _, assignee := range assignees { + if !assigneesContains(removedAssignees, assignee) { + newAssignees = append(newAssignees, assignee) + } + } + + return newAssignees +} + +func assigneesContains(assignees []data.Assignee, assignee data.Assignee) bool { + for _, a := range assignees { + if assignee == a { + return true + } + } + return false +} + +func (m Model) GetItemSingularForm() string { + return "PR" +} + +func (m Model) GetItemPluralForm() string { + return "PRs" +} + +func (m Model) GetTotalCount() *int { + if m.IsLoading() { + return nil + } + return &m.TotalCount +} + +func (m Model) IsLoading() bool { + return m.Table.IsLoading() +} diff --git a/ui/components/table/table.go b/ui/components/table/table.go index 079296ee..97ff06db 100644 --- a/ui/components/table/table.go +++ b/ui/components/table/table.go @@ -27,10 +27,11 @@ type Model struct { } type Column struct { - Title string - Hidden *bool - Width *int - Grow *bool + Title string + Hidden *bool + Width *int + ComputedWidth int + Grow *bool } type Row []string @@ -143,8 +144,19 @@ func (m *Model) LastItem() int { return currItem } +func (m *Model) cacheColumnWidths() { + columns := m.renderHeaderColumns() + for i, col := range columns { + if m.Columns[i].Hidden != nil && *m.Columns[i].Hidden { + continue + } + m.Columns[i].ComputedWidth = lipgloss.Width(col) + } +} + func (m *Model) SyncViewPortContent() { headerColumns := m.renderHeaderColumns() + m.cacheColumnWidths() renderedRows := make([]string, 0, len(m.Rows)) for i := range m.Rows { renderedRows = append(renderedRows, m.renderRow(i, headerColumns)) diff --git a/ui/components/tasks/pr.go b/ui/components/tasks/pr.go new file mode 100644 index 00000000..6f74047a --- /dev/null +++ b/ui/components/tasks/pr.go @@ -0,0 +1,161 @@ +package tasks + +import ( + "fmt" + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/dlvhdr/gh-dash/v4/data" + "github.com/dlvhdr/gh-dash/v4/ui/constants" + "github.com/dlvhdr/gh-dash/v4/ui/context" + "github.com/dlvhdr/gh-dash/v4/utils" +) + +type SectionIdentifer struct { + Id int + Type string +} + +type UpdatePRMsg struct { + PrNumber int + IsClosed *bool + NewComment *data.Comment + ReadyForReview *bool + IsMerged *bool + AddedAssignees *data.Assignees + RemovedAssignees *data.Assignees +} + +func buildTaskId(prefix string, prNumber int) string { + return fmt.Sprintf("%s_%d", prefix, prNumber) +} + +type GitHubTask struct { + Id string + Args []string + Section SectionIdentifer + StartText string + FinishedText string + Msg func(c *exec.Cmd, err error) UpdatePRMsg +} + +func fireTask(ctx *context.ProgramContext, task GitHubTask) tea.Cmd { + start := context.Task{ + Id: task.Id, + StartText: task.StartText, + FinishedText: task.FinishedText, + State: context.TaskStart, + Error: nil, + } + + startCmd := ctx.StartTask(start) + return tea.Batch(startCmd, func() tea.Msg { + c := exec.Command("gh", task.Args...) + + err := c.Run() + return constants.TaskFinishedMsg{ + TaskId: task.Id, + SectionId: task.Section.Id, + SectionType: task.Section.Type, + Err: err, + Msg: task.Msg(c, err), + } + }) +} + +func ReopenPR(ctx *context.ProgramContext, section SectionIdentifer, pr data.RowData) tea.Cmd { + prNumber := pr.GetNumber() + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_reopen", prNumber), + Args: []string{ + "pr", + "reopen", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + }, + Section: section, + StartText: fmt.Sprintf("Reopening PR #%d", prNumber), + FinishedText: fmt.Sprintf("PR #%d has been reopened", prNumber), + Msg: func(c *exec.Cmd, err error) UpdatePRMsg { + return UpdatePRMsg{ + PrNumber: prNumber, + IsClosed: utils.BoolPtr(false), + } + }, + }) +} + +func ClosePR(ctx *context.ProgramContext, section SectionIdentifer, pr data.RowData) tea.Cmd { + prNumber := pr.GetNumber() + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_close", prNumber), + Args: []string{ + "pr", + "close", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + }, + Section: section, + StartText: fmt.Sprintf("Closing PR #%d", prNumber), + FinishedText: fmt.Sprintf("PR #%d has been closed", prNumber), + Msg: func(c *exec.Cmd, err error) UpdatePRMsg { + return UpdatePRMsg{ + PrNumber: prNumber, + IsClosed: utils.BoolPtr(true), + } + }, + }) +} + +func PRReady(ctx *context.ProgramContext, section SectionIdentifer, pr data.RowData) tea.Cmd { + prNumber := pr.GetNumber() + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_ready", prNumber), + Args: []string{ + "pr", + "ready", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + }, + Section: section, + StartText: fmt.Sprintf("Marking PR #%d as ready for review", prNumber), + FinishedText: fmt.Sprintf("PR #%d has been marked as ready for review", prNumber), + Msg: func(c *exec.Cmd, err error) UpdatePRMsg { + return UpdatePRMsg{ + PrNumber: prNumber, + ReadyForReview: utils.BoolPtr(true), + } + }, + }) +} + +func MergePR(ctx *context.ProgramContext, section SectionIdentifer, pr data.RowData) tea.Cmd { + prNumber := pr.GetNumber() + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_merge", prNumber), + Args: []string{ + "pr", + "merge", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + }, + Section: section, + StartText: fmt.Sprintf("Merging PR #%d", prNumber), + FinishedText: fmt.Sprintf("PR #%d has been merged", prNumber), + Msg: func(c *exec.Cmd, err error) UpdatePRMsg { + isMerged := false + if err == nil && c.ProcessState.ExitCode() == 0 { + isMerged = true + } + return UpdatePRMsg{ + PrNumber: prNumber, + IsMerged: &isMerged, + } + }, + }) +} diff --git a/ui/components/utils.go b/ui/components/utils.go index 16b0bd50..45be4282 100644 --- a/ui/components/utils.go +++ b/ui/components/utils.go @@ -24,7 +24,6 @@ func FormatNumber(num int) string { func GetIssueTextStyle( ctx *context.ProgramContext, - state string, ) lipgloss.Style { return lipgloss.NewStyle().Foreground(ctx.Theme.PrimaryText) } @@ -51,7 +50,7 @@ func RenderIssueTitle( } - rTitle := GetIssueTextStyle(ctx, state).Render(title) + rTitle := GetIssueTextStyle(ctx).Render(title) res := fmt.Sprintf("%s%s", prNumber, rTitle) return res diff --git a/ui/context/context.go b/ui/context/context.go index 515cdc8f..b0ce4833 100644 --- a/ui/context/context.go +++ b/ui/context/context.go @@ -4,10 +4,11 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/log" "github.com/dlvhdr/gh-dash/v4/config" + "github.com/dlvhdr/gh-dash/v4/git" "github.com/dlvhdr/gh-dash/v4/ui/theme" + "github.com/dlvhdr/gh-dash/v4/utils" ) type State = int @@ -29,6 +30,8 @@ type Task struct { } type ProgramContext struct { + RepoPath *string + Repo *git.Repo User string ScreenHeight int ScreenWidth int @@ -45,16 +48,20 @@ type ProgramContext struct { func (ctx *ProgramContext) GetViewSectionsConfig() []config.SectionConfig { var configs []config.SectionConfig - log.Debug("View", "view", ctx.View) switch ctx.View { + case config.RepoView: + t := config.RepoView + configs = append(configs, config.PrsSectionConfig{ + Title: "Local Branches", + Filters: "author:@me is:open", + Limit: utils.IntPtr(20), + Type: &t, + }.ToSectionConfig()) case config.PRsView: - log.Debug("sections", "prs", ctx.Config.PRSections) for _, cfg := range ctx.Config.PRSections { configs = append(configs, cfg.ToSectionConfig()) } case config.IssuesView: - log.Debug("HelPPPPPPPPPPPPPPP", "config", ctx.Config, "view", ctx.View) - log.Debug("sections", "issues", ctx.Config.IssuesSections) for _, cfg := range ctx.Config.IssuesSections { configs = append(configs, cfg.ToSectionConfig()) } diff --git a/ui/keys/keys.go b/ui/keys/keys.go index 541f65b3..c7bb68a3 100644 --- a/ui/keys/keys.go +++ b/ui/keys/keys.go @@ -42,7 +42,7 @@ func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) FullHelp() [][]key.Binding { var additionalKeys []key.Binding - if k.viewType == config.PRsView { + if k.viewType == config.PRsView || k.viewType == config.RepoView { additionalKeys = PRFullHelp() } else { additionalKeys = IssueFullHelp() diff --git a/ui/modelUtils.go b/ui/modelUtils.go index 6a258ca1..f4080563 100644 --- a/ui/modelUtils.go +++ b/ui/modelUtils.go @@ -82,7 +82,7 @@ func (m *Model) executeKeybinding(key string) tea.Cmd { return m.runCustomIssueCommand(keybinding.Command, data) } } - case config.PRsView: + case config.PRsView, config.RepoView: for _, keybinding := range m.ctx.Config.Keybindings.Prs { if keybinding.Key != key || keybinding.Command == "" { continue diff --git a/ui/repo.go b/ui/repo.go new file mode 100644 index 00000000..5b1faa29 --- /dev/null +++ b/ui/repo.go @@ -0,0 +1 @@ +package ui diff --git a/ui/ui.go b/ui/ui.go index e27ed810..e7fed86d 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -23,6 +23,7 @@ import ( "github.com/dlvhdr/gh-dash/v4/ui/components/issuessection" "github.com/dlvhdr/gh-dash/v4/ui/components/prsidebar" "github.com/dlvhdr/gh-dash/v4/ui/components/prssection" + "github.com/dlvhdr/gh-dash/v4/ui/components/reposection" "github.com/dlvhdr/gh-dash/v4/ui/components/section" "github.com/dlvhdr/gh-dash/v4/ui/components/sidebar" "github.com/dlvhdr/gh-dash/v4/ui/components/tabs" @@ -39,6 +40,7 @@ type Model struct { issueSidebar issuesidebar.Model currSectionId int footer footer.Model + repo []section.Section prs []section.Section issues []section.Section tabs tabs.Model @@ -47,7 +49,7 @@ type Model struct { tasks map[string]context.Task } -func NewModel(configPath string) Model { +func NewModel(repoPath *string, configPath string) Model { taskSpinner := spinner.Model{Spinner: spinner.Dot} m := Model{ keys: keys.Keys, @@ -58,6 +60,7 @@ func NewModel(configPath string) Model { } m.ctx = context.ProgramContext{ + RepoPath: repoPath, ConfigPath: configPath, StartTask: func(task context.Task) tea.Cmd { log.Debug("Starting task", "id", task.Id) @@ -144,7 +147,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Debug("Key pressed", "key", msg.String()) m.ctx.Error = nil - if currSection != nil && currSection.IsSearchFocused() || currSection.IsPromptConfirmationFocused() { + if currSection != nil && (currSection.IsSearchFocused() || currSection.IsPromptConfirmationFocused()) { cmd = m.updateSection(currSection.GetId(), currSection.GetType(), msg) return m, cmd } @@ -263,6 +266,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, cmd + // this is a change case key.Matches(msg, m.keys.Quit): if m.ctx.Config.ConfirmQuit { m.footer, cmd = m.footer.Update(msg) @@ -270,7 +274,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmd = tea.Quit - case m.ctx.View == config.PRsView: + case m.ctx.View == config.PRsView, m.ctx.View == config.RepoView: switch { case key.Matches(msg, keys.PRKeys.Approve): m.sidebar.IsOpen = true @@ -407,9 +411,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ctx.Theme = theme.ParseTheme(m.ctx.Config) m.ctx.Styles = context.InitStyles(m.ctx.Theme) log.Debug("Config loaded", "default view", m.ctx.Config.Defaults.View) - log.Debug("ui.ui initMsg", "ctx", m.ctx) m.ctx.View = m.ctx.Config.Defaults.View - log.Debug("View set", "view", m.ctx.View) m.sidebar.IsOpen = msg.Config.Defaults.Preview.Open m.tabs.UpdateSectionsConfigs(&m.ctx) m.syncMainContentWidth() @@ -464,10 +466,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd = m.updateRelevantSection(msg) if msg.Id == m.currSectionId { - switch msg.Type { - case prssection.SectionType, issuessection.SectionType: - m.onViewedRowChanged() - } + m.onViewedRowChanged() } case tea.WindowSizeMsg: @@ -529,11 +528,15 @@ func (m Model) View() string { s.WriteString("\n") currSection := m.getCurrSection() mainContent := "" + parts := make([]string, 0, 2) if currSection != nil { + parts = append(parts, m.getCurrSection().View()) + if m.ctx.View != config.RepoView { + parts = append(parts, m.sidebar.View()) + } mainContent = lipgloss.JoinHorizontal( lipgloss.Top, - m.getCurrSection().View(), - m.sidebar.View(), + parts..., ) } else { mainContent = "No sections defined..." @@ -597,6 +600,9 @@ func (m *Model) syncProgramContext() { func (m *Model) updateSection(id int, sType string, msg tea.Msg) (cmd tea.Cmd) { var updatedSection section.Section switch sType { + case reposection.SectionType: + updatedSection, cmd = m.repo[id].Update(msg) + m.repo[id] = updatedSection case prssection.SectionType: updatedSection, cmd = m.prs[id].Update(msg) m.prs[id] = updatedSection @@ -629,6 +635,9 @@ func (m *Model) syncMainContentWidth() { } func (m *Model) syncSidebar() { + if m.ctx.View == config.RepoView { + return + } currRowData := m.getCurrRowData() width := m.sidebar.GetSidebarContentWidth() @@ -652,7 +661,9 @@ func (m *Model) syncSidebar() { } func (m *Model) fetchAllViewSections() ([]section.Section, tea.Cmd) { - if m.ctx.View == config.PRsView { + if m.ctx.View == config.RepoView { + return reposection.FetchAllBranches(m.ctx) + } else if m.ctx.View == config.PRsView { return prssection.FetchAllSections(m.ctx) } else { return issuessection.FetchAllSections(m.ctx) @@ -660,7 +671,9 @@ func (m *Model) fetchAllViewSections() ([]section.Section, tea.Cmd) { } func (m *Model) getCurrentViewSections() []section.Section { - if m.ctx.View == config.PRsView { + if m.ctx.View == config.RepoView { + return m.repo + } else if m.ctx.View == config.PRsView { return m.prs } else { return m.issues @@ -668,7 +681,18 @@ func (m *Model) getCurrentViewSections() []section.Section { } func (m *Model) setCurrentViewSections(newSections []section.Section) { - if m.ctx.View == config.PRsView { + if m.ctx.View == config.RepoView { + search := prssection.NewModel( + 0, + &m.ctx, + config.PrsSectionConfig{ + Title: "", + Filters: "archived:false", + }, + time.Now(), + ) + m.repo = append([]section.Section{&search}, newSections...) + } else if m.ctx.View == config.PRsView { search := prssection.NewModel( 0, &m.ctx, @@ -694,9 +718,23 @@ func (m *Model) setCurrentViewSections(newSections []section.Section) { } func (m *Model) switchSelectedView() config.ViewType { - if m.ctx.View == config.PRsView { + repoFF := config.IsFeatureEnabled(config.FF_REPO_VIEW) + + if repoFF { + switch true { + case m.ctx.View == config.RepoView: + return config.PRsView + case m.ctx.View == config.PRsView: + return config.IssuesView + case m.ctx.View == config.IssuesView: + return config.RepoView + } + } + + switch true { + case m.ctx.View == config.PRsView: return config.IssuesView - } else { + default: return config.PRsView } }