diff --git a/cmd/root.go b/cmd/root.go index 609aa8791..4b8145b2d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,18 +13,21 @@ import ( "runtime/pprof" "time" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "charm.land/log/v2" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/fang" - zone "github.com/lrstanley/bubblezone/v2" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/log" + zone "github.com/lrstanley/bubblezone" + "github.com/muesli/termenv" "github.com/spf13/cobra" "github.com/dlvhdr/gh-dash/v4/internal/config" "github.com/dlvhdr/gh-dash/v4/internal/git" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" dctx "github.com/dlvhdr/gh-dash/v4/internal/tui/context" + "github.com/dlvhdr/gh-dash/v4/internal/tui/markdown" ) var ( @@ -42,9 +45,9 @@ var ( rootCmd = &cobra.Command{ Use: "gh dash", Long: lipgloss.JoinVertical(lipgloss.Left, logo.Render(), - "A rich terminal UI for GitHub that doesn't break your flow.", + "A rich terminal UI for GitHub/GitLab that doesn't break your flow.", "Visit https://gh-dash.dev for the docs."), - Short: "A rich terminal UI for GitHub that doesn't break your flow.", + Short: "A rich terminal UI for GitHub/GitLab that doesn't break your flow.", Version: "", Example: ` # Running without arguments will either: @@ -66,13 +69,8 @@ gh dash -v ) func Execute() { - if err := fang.Execute( - context.Background(), - rootCmd, - fang.WithVersion(rootCmd.Version), - fang.WithoutCompletions(), - fang.WithoutManpage(), - ); err != nil { + if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(rootCmd.Version), + fang.WithoutCompletions(), fang.WithoutManpage()); err != nil { os.Exit(1) } } @@ -131,12 +129,7 @@ func buildVersion(version, commit, date, builtBy string) string { } result = fmt.Sprintf("%s\ngoos: %s\ngoarch: %s", result, runtime.GOOS, runtime.GOARCH) if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { - result = fmt.Sprintf( - "%s\nmodule version: %s, checksum: %s", - result, - info.Main.Version, - info.Main.Sum, - ) + result = fmt.Sprintf("%s\nmodule version: %s, checksum: %s", result, info.Main.Version, info.Main.Sum) } return result @@ -183,6 +176,7 @@ func init() { ) rootCmd.Run = func(_ *cobra.Command, args []string) { + provider.SetProvider(provider.NewGitHubProvider()) var repo string repos := config.IsFeatureEnabled(config.FF_REPO_VIEW) if repos && len(args) > 0 { @@ -202,6 +196,10 @@ func init() { zone.NewGlobal() + // see https://github.com/charmbracelet/lipgloss/issues/73 + lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) + markdown.InitializeMarkdownStyle(termenv.HasDarkBackground()) + model, logger := createModel(config.Location{RepoPath: repo, ConfigFlag: cfgFlag}, debug) if logger != nil { defer logger.Close() @@ -220,7 +218,12 @@ func init() { defer pprof.StopCPUProfile() } - p := tea.NewProgram(model) + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithReportFocus(), + tea.WithMouseCellMotion(), + ) if _, err := p.Run(); err != nil { log.Fatal("Failed starting the TUI", err) } diff --git a/internal/provider/github.go b/internal/provider/github.go new file mode 100644 index 000000000..e69f10d60 --- /dev/null +++ b/internal/provider/github.go @@ -0,0 +1,54 @@ +package provider + +// GitHubProvider implements the Provider interface for GitHub +// GitHub uses the gh CLI and the existing data package functions +type GitHubProvider struct { + host string +} + +// NewGitHubProvider creates a new GitHub provider +func NewGitHubProvider() *GitHubProvider { + return &GitHubProvider{ + host: "github.com", + } +} + +func (g *GitHubProvider) GetType() ProviderType { + return GitHub +} + +func (g *GitHubProvider) GetHost() string { + return g.host +} + +func (g *GitHubProvider) GetCLICommand() string { + return "gh" +} + +// FetchPullRequests is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchPullRequests should not be called directly") +} + +// FetchPullRequest is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchPullRequest should not be called directly") +} + +// FetchIssues is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchIssues should not be called directly") +} + +// GetCurrentUser is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) GetCurrentUser() (string, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.GetCurrentUser should not be called directly") +} diff --git a/internal/provider/instance.go b/internal/provider/instance.go new file mode 100644 index 000000000..c3e0dc253 --- /dev/null +++ b/internal/provider/instance.go @@ -0,0 +1,39 @@ +package provider + +import ( + "sync" +) + +var ( + currentProvider Provider + providerMu sync.RWMutex +) + +// SetProvider sets the global provider instance +func SetProvider(p Provider) { + providerMu.Lock() + defer providerMu.Unlock() + currentProvider = p +} + +// GetProvider returns the current provider instance +// Defaults to GitHub if not set +func GetProvider() Provider { + providerMu.RLock() + defer providerMu.RUnlock() + if currentProvider == nil { + return NewGitHubProvider() + } + return currentProvider +} + +// IsGitHub returns true if the current provider is GitHub +func IsGitHub() bool { + p := GetProvider() + return p.GetType() == GitHub +} + +// GetCLICommand returns the CLI command for the current provider +func GetCLICommand() string { + return GetProvider().GetCLICommand() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 000000000..ea6e7a301 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,294 @@ +package provider + +import ( + "time" +) + +// ProviderType represents the type of Git hosting provider +type ProviderType string + +const ( + GitHub ProviderType = "github" +) + +// Provider is the interface that abstracts provider APIs +type Provider interface { + // GetType returns the provider type + GetType() ProviderType + + // GetHost returns the hostname (e.g., "gitlab.krone.at" or "github.com") + GetHost() string + + // FetchPullRequests fetches pull/merge requests + FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) + + // FetchPullRequest fetches a single pull/merge request by URL + FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) + + // FetchIssues fetches issues + FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) + + // GetCurrentUser returns the current authenticated user + GetCurrentUser() (string, error) + + // GetCLICommand returns the CLI command name + GetCLICommand() string +} + +// PageInfo for pagination +type PageInfo struct { + HasNextPage bool + StartCursor string + EndCursor string +} + +// Repository represents a repository +type Repository struct { + NameWithOwner string + IsArchived bool +} + +// Author represents an author +type Author struct { + Login string +} + +// Assignee represents an assignee +type Assignee struct { + Login string +} + +// Assignees represents a list of assignees +type Assignees struct { + Nodes []Assignee +} + +// Label represents a label +type Label struct { + Color string + Name string +} + +// Labels represents a list of labels +type Labels struct { + Nodes []Label +} + +// Comment represents a comment +type Comment struct { + Author Author + Body string + UpdatedAt time.Time +} + +// Comments represents comment counts +type Comments struct { + TotalCount int +} + +// CommentsWithBody includes full comment data +type CommentsWithBody struct { + TotalCount int + Nodes []Comment +} + +// Review represents a review +type Review struct { + Author Author + Body string + State string + UpdatedAt time.Time +} + +// Reviews represents reviews +type Reviews struct { + TotalCount int + Nodes []Review +} + +// ReviewThreads represents review threads +type ReviewThreads struct { + TotalCount int +} + +// ReviewRequests represents review requests +type ReviewRequests struct { + TotalCount int + Nodes []ReviewRequestNode +} + +// ReviewRequestNode represents a single review request +type ReviewRequestNode struct { + AsCodeOwner bool + RequestedReviewer struct { + User struct{ Login string } + Team struct{ Slug, Name string } + Bot struct{ Login string } + Mannequin struct{ Login string } + } +} + +// GetReviewerDisplayName returns the display name for a reviewer +func (r ReviewRequestNode) GetReviewerDisplayName() string { + if r.RequestedReviewer.User.Login != "" { + return r.RequestedReviewer.User.Login + } + if r.RequestedReviewer.Team.Slug != "" { + return r.RequestedReviewer.Team.Slug + } + if r.RequestedReviewer.Bot.Login != "" { + return r.RequestedReviewer.Bot.Login + } + if r.RequestedReviewer.Mannequin.Login != "" { + return r.RequestedReviewer.Mannequin.Login + } + return "" +} + +// GetReviewerType returns the type of reviewer +func (r ReviewRequestNode) GetReviewerType() string { + if r.RequestedReviewer.User.Login != "" { + return "User" + } + if r.RequestedReviewer.Team.Slug != "" { + return "Team" + } + if r.RequestedReviewer.Bot.Login != "" { + return "Bot" + } + if r.RequestedReviewer.Mannequin.Login != "" { + return "Mannequin" + } + return "" +} + +// IsTeam returns true if the reviewer is a team +func (r ReviewRequestNode) IsTeam() bool { + return r.RequestedReviewer.Team.Slug != "" +} + +// ChangedFile represents a changed file +type ChangedFile struct { + Additions int + Deletions int + Path string + ChangeType string +} + +// ChangedFiles represents changed files +type ChangedFiles struct { + TotalCount int + Nodes []ChangedFile +} + +// Commits represents commits +type Commits struct { + TotalCount int +} + +// PullRequestData represents a pull/merge request +type PullRequestData struct { + Number int + Title string + Body string + Author Author + AuthorAssociation string + UpdatedAt time.Time + CreatedAt time.Time + Url string + State string + Mergeable string + ReviewDecision string + Additions int + Deletions int + HeadRefName string + BaseRefName string + HeadRepository struct{ Name string } + HeadRef struct{ Name string } + Repository Repository + Assignees Assignees + Comments Comments + ReviewThreads ReviewThreads + Reviews Reviews + ReviewRequests ReviewRequests + Files ChangedFiles + IsDraft bool + Commits Commits + Labels Labels + MergeStateStatus string + CIStatus string // "success", "failure", "pending", "" +} + +// PullRequestsResponse represents a list of pull requests +type PullRequestsResponse struct { + Prs []PullRequestData + TotalCount int + PageInfo PageInfo +} + +// EnrichedPullRequestData represents enriched pull request data +type EnrichedPullRequestData struct { + Url string + Number int + Repository Repository + Comments CommentsWithBody + ReviewThreads ReviewThreads + ReviewRequests ReviewRequests + Reviews Reviews + Commits []CommitData +} + +// CommitData represents commit data +type CommitData struct { + AbbreviatedOid string + CommittedDate time.Time + MessageHeadline string + Author struct { + Name string + User struct{ Login string } + } + StatusCheckRollup string +} + +// IssueData represents an issue +type IssueData struct { + Number int + Title string + Body string + State string + Author Author + AuthorAssociation string + UpdatedAt time.Time + CreatedAt time.Time + Url string + Repository Repository + Assignees Assignees + Comments IssueComments + Reactions IssueReactions + Labels Labels +} + +// IssueComments represents issue comments +type IssueComments struct { + Nodes []IssueComment + TotalCount int +} + +// IssueComment represents an issue comment +type IssueComment struct { + Author Author + Body string + UpdatedAt time.Time +} + +// IssueReactions represents issue reactions +type IssueReactions struct { + TotalCount int +} + +// IssuesResponse represents a list of issues +type IssuesResponse struct { + Issues []IssueData + TotalCount int + PageInfo PageInfo +}