Skip to content

Commit

Permalink
feat(branch): checkout and delete (#422)
Browse files Browse the repository at this point in the history
* feat: checkout branch

* feat: show commit message if no PR

* feat: put checked out branch at the top

* feat: swap title and branch name and show sidebar

* feat: delete branch
  • Loading branch information
dlvhdr authored Aug 17, 2024
1 parent f62afad commit 8eea0c6
Show file tree
Hide file tree
Showing 15 changed files with 685 additions and 34 deletions.
1 change: 1 addition & 0 deletions config/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ type Keybindings struct {
Universal []Keybinding `yaml:"universal"`
Issues []Keybinding `yaml:"issues"`
Prs []Keybinding `yaml:"prs"`
Branches []Keybinding `yaml:"branches"`
}

type Pager struct {
Expand Down
41 changes: 40 additions & 1 deletion git/git.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package git

import (
"errors"
"sort"
"time"

gitm "github.com/aymanbagabas/git-module"

"github.com/dlvhdr/gh-dash/v4/utils"
)

// Extends git.Repository
Expand All @@ -17,6 +20,33 @@ type Repo struct {
type Branch struct {
Name string
LastUpdatedAt *time.Time
LastCommitMsg *string
IsCheckedOut bool
}

func GetOriginUrl(dir string) (string, error) {
repo, err := gitm.Open(dir)
if err != nil {
return "", err
}
remotes, err := repo.Remotes()
if err != nil {
return "", err
}

for _, remote := range remotes {
if remote != "origin" {
continue
}

urls, err := gitm.RemoteGetURL(dir, remote)
if err != nil || len(urls) == 0 {
return "", err
}
return urls[0], nil
}

return "", errors.New("no origin remote found")
}

func GetRepo(dir string) (*Repo, error) {
Expand All @@ -30,14 +60,23 @@ func GetRepo(dir string) (*Repo, error) {
return nil, err
}

headRev, err := repo.RevParse("HEAD")
if err != nil {
return nil, err
}

branches := make([]Branch, len(bNames))
for i, b := range bNames {
var updatedAt *time.Time
var lastCommitMsg *string
isHead := false
commits, err := gitm.Log(dir, b, gitm.LogOptions{MaxCount: 1})
if err == nil && len(commits) > 0 {
updatedAt = &commits[0].Committer.When
isHead = commits[0].ID.Equal(headRev)
lastCommitMsg = utils.StringPtr(commits[0].Summary())
}
branches[i] = Branch{Name: b, LastUpdatedAt: updatedAt}
branches[i] = Branch{Name: b, LastUpdatedAt: updatedAt, IsCheckedOut: isHead, LastCommitMsg: lastCommitMsg}
}
sort.Slice(branches, func(i, j int) bool {
if branches[j].LastUpdatedAt == nil || branches[i].LastUpdatedAt == nil {
Expand Down
305 changes: 305 additions & 0 deletions ui/components/branch/branch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
package branch

import (
"fmt"
"strings"

"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"
"github.com/dlvhdr/gh-dash/v4/ui/context"
"github.com/dlvhdr/gh-dash/v4/utils"
)

type Branch struct {
Ctx *context.ProgramContext
PR *data.PullRequestData
Data git.Branch
Columns []table.Column
}

func (b *Branch) getTextStyle() lipgloss.Style {
return components.GetIssueTextStyle(b.Ctx)
}

func (b *Branch) renderReviewStatus() string {
if b.PR == nil {
return "-"
}
reviewCellStyle := b.getTextStyle()
if b.PR.ReviewDecision == "APPROVED" {
reviewCellStyle = reviewCellStyle.Foreground(
b.Ctx.Theme.SuccessText,
)
return reviewCellStyle.Render("󰄬")
}

if b.PR.ReviewDecision == "CHANGES_REQUESTED" {
reviewCellStyle = reviewCellStyle.Foreground(
b.Ctx.Theme.WarningText,
)
return reviewCellStyle.Render("󰌑")
}

return reviewCellStyle.Render(b.Ctx.Styles.Common.WaitingGlyph)
}

func (b *Branch) renderState() string {
mergeCellStyle := lipgloss.NewStyle()

if b.PR == nil {
return mergeCellStyle.Foreground(b.Ctx.Theme.SuccessText).Render("󰜛")
}

switch b.PR.State {
case "OPEN":
if b.PR.IsDraft {
return mergeCellStyle.Foreground(b.Ctx.Theme.FaintText).Render(constants.DraftIcon)
} else {
return mergeCellStyle.Foreground(b.Ctx.Styles.Colors.OpenPR).Render(constants.OpenIcon)
}
case "CLOSED":
return mergeCellStyle.Foreground(b.Ctx.Styles.Colors.ClosedPR).
Render(constants.ClosedIcon)
case "MERGED":
return mergeCellStyle.Foreground(b.Ctx.Styles.Colors.MergedPR).
Render(constants.MergedIcon)
default:
return mergeCellStyle.Foreground(b.Ctx.Theme.FaintText).Render("-")
}
}

func (b *Branch) GetStatusChecksRollup() string {
if b.PR.Mergeable == "CONFLICTING" {
return "FAILURE"
}

accStatus := "SUCCESS"
commits := b.PR.Commits.Nodes
if len(commits) == 0 {
return "PENDING"
}

mostRecentCommit := commits[0].Commit
for _, statusCheck := range mostRecentCommit.StatusCheckRollup.Contexts.Nodes {
var conclusion string
if statusCheck.Typename == "CheckRun" {
conclusion = string(statusCheck.CheckRun.Conclusion)
status := string(statusCheck.CheckRun.Status)
if isStatusWaiting(status) {
accStatus = "PENDING"
}
} else if statusCheck.Typename == "StatusContext" {
conclusion = string(statusCheck.StatusContext.State)
if isStatusWaiting(conclusion) {
accStatus = "PENDING"
}
}

if isConclusionAFailure(conclusion) {
accStatus = "FAILURE"
break
}
}

return accStatus
}

func (b *Branch) renderCiStatus() string {
if b.PR == nil {
return "-"
}

accStatus := b.GetStatusChecksRollup()
ciCellStyle := b.getTextStyle()
if accStatus == "SUCCESS" {
ciCellStyle = ciCellStyle.Foreground(b.Ctx.Theme.SuccessText)
return ciCellStyle.Render(constants.SuccessIcon)
}

if accStatus == "PENDING" {
return ciCellStyle.Render(b.Ctx.Styles.Common.WaitingGlyph)
}

ciCellStyle = ciCellStyle.Foreground(b.Ctx.Theme.WarningText)
return ciCellStyle.Render(constants.FailureIcon)
}

func (b *Branch) renderLines(isSelected bool) string {
if b.PR == nil {
return "-"
}
deletions := 0
if b.PR.Deletions > 0 {
deletions = b.PR.Deletions
}

var additionsFg, deletionsFg lipgloss.AdaptiveColor
additionsFg = b.Ctx.Theme.SuccessText
deletionsFg = b.Ctx.Theme.WarningText

baseStyle := lipgloss.NewStyle()
if isSelected {
baseStyle = baseStyle.Background(b.Ctx.Theme.SelectedBackground)
}

additionsText := baseStyle.Copy().
Foreground(additionsFg).
Render(fmt.Sprintf("+%s", components.FormatNumber(b.PR.Additions)))
deletionsText := baseStyle.Copy().
Foreground(deletionsFg).
Render(fmt.Sprintf("-%s", components.FormatNumber(deletions)))

return b.getTextStyle().Render(
keepSameSpacesOnAddDeletions(
lipgloss.JoinHorizontal(
lipgloss.Left,
additionsText,
baseStyle.Render(" "),
deletionsText,
)),
)
}

func (b *Branch) renderTitle() string {
return components.RenderIssueTitle(
b.Ctx,
b.PR.State,
b.PR.Title,
b.PR.Number,
)
}

func (b *Branch) renderExtendedTitle(isSelected bool) string {
baseStyle := lipgloss.NewStyle()
if isSelected {
baseStyle = baseStyle.Background(b.Ctx.Theme.SelectedBackground)
}

title := "-"
if b.PR != nil {
title = fmt.Sprintf("#%d %s", b.PR.Number, b.PR.Title)
} else if b.Data.LastCommitMsg != nil {
title = *b.Data.LastCommitMsg
}
var titleColumn table.Column
for _, column := range b.Columns {
if column.Title == "Title" {
titleColumn = column
}
}
width := titleColumn.ComputedWidth - 2
title = baseStyle.Copy().Foreground(b.Ctx.Theme.SecondaryText).Width(width).MaxWidth(width).Render(title)
name := b.Data.Name
if b.Data.IsCheckedOut {
name = baseStyle.Foreground(b.Ctx.Theme.SuccessText).Render(" " + name)
} else {
name = baseStyle.Foreground(b.Ctx.Theme.PrimaryText).Render(name)
}
top := baseStyle.Width(width).MaxWidth(width).Render(name)

return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, top, title))
}

func (pr *Branch) renderAuthor() string {
return pr.getTextStyle().Render(pr.PR.Author.Login)
}

func (b *Branch) renderAssignees() string {
if b.PR == nil {
return ""
}
assignees := make([]string, 0, len(b.PR.Assignees.Nodes))
for _, assignee := range b.PR.Assignees.Nodes {
assignees = append(assignees, assignee.Login)
}
return b.getTextStyle().Render(strings.Join(assignees, ","))
}

func (b *Branch) renderRepoName() string {
repoName := ""
if !b.Ctx.Config.Theme.Ui.Table.Compact {
repoName = b.PR.Repository.NameWithOwner
} else {
repoName = b.PR.HeadRepository.Name
}
return b.getTextStyle().Copy().Foreground(b.Ctx.Theme.FaintText).Render(repoName)
}

func (b *Branch) renderUpdateAt() string {
timeFormat := b.Ctx.Config.Defaults.DateFormat

updatedAtOutput := ""
t := b.Data.LastUpdatedAt
if b.PR != nil {
t = &b.PR.UpdatedAt
}

if t == nil {
return ""
}

if timeFormat == "" || timeFormat == "relative" {
updatedAtOutput = utils.TimeElapsed(*t)
} else {
updatedAtOutput = t.Format(timeFormat)
}

return b.getTextStyle().Copy().Foreground(b.Ctx.Theme.FaintText).Render(updatedAtOutput)
}

func (b *Branch) renderBaseName() string {
if b.PR == nil {
return ""
}
return b.getTextStyle().Render(b.PR.BaseRefName)
}

func (b *Branch) RenderState() string {
switch b.PR.State {
case "OPEN":
if b.PR.IsDraft {
return constants.DraftIcon + " Draft"
} else {
return constants.OpenIcon + " Open"
}
case "CLOSED":
return constants.ClosedIcon + " Closed"
case "MERGED":
return constants.MergedIcon + " Merged"
default:
return ""
}
}

func (b *Branch) ToTableRow(isSelected bool) table.Row {
if !b.Ctx.Config.Theme.Ui.Table.Compact {
return table.Row{
b.renderState(),
b.renderExtendedTitle(isSelected),
b.renderBaseName(),
b.renderAssignees(),
b.renderReviewStatus(),
b.renderCiStatus(),
b.renderLines(isSelected),
b.renderUpdateAt(),
}
}

return table.Row{
b.renderState(),
b.renderRepoName(),
b.renderTitle(),
b.renderAuthor(),
b.renderBaseName(),
b.renderAssignees(),
b.renderReviewStatus(),
b.renderCiStatus(),
b.renderLines(isSelected),
b.renderUpdateAt(),
}
}
Loading

0 comments on commit 8eea0c6

Please sign in to comment.