From 216a78b0bd4fc57130aeb67f50881c0d934eccf4 Mon Sep 17 00:00:00 2001 From: Dhruv Thakur <13575379+dhth@users.noreply.github.com> Date: Fri, 2 Aug 2024 06:43:04 +0530 Subject: [PATCH] feat: add pr details pane (#8) - Adds a "PR Details" pane which lists the following - PR Metadata - Description - Status checks - Issues referenced - Files changed - Commits - Comments - Adds better navigation for review comments pane --- .gitignore | 2 +- cmd/config.go | 4 +- cmd/help.go | 2 +- cmd/root.go | 14 +- cosign.pub | 4 + internal/utils/assets/gruvbox.json | 8 +- ui/assets/help.md | 64 ++- ui/cmds.go | 17 +- ui/colors.go | 329 ++++++------- ui/gh.go | 28 ++ ui/initial.go | 29 +- ui/model.go | 64 +-- ui/msgs.go | 8 + ui/navigation.go | 160 ++++++ ui/render_helpers.go | 21 +- ui/styles.go | 5 +- ui/types.go | 560 +++++++++++++++++++-- ui/update.go | 748 ++++++++++++++++++++++------- ui/view.go | 24 +- 19 files changed, 1601 insertions(+), 490 deletions(-) create mode 100644 cosign.pub create mode 100644 ui/navigation.go diff --git a/.gitignore b/.gitignore index 566fbdf..5a84c79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ prs .quickrun cosign.key -cosign.pub justfile +debug.log diff --git a/cmd/config.go b/cmd/config.go index 73a7322..c3c46ec 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -10,8 +10,8 @@ import ( ) const ( - defaultPRCount = 30 - maxPRCount = 100 + defaultPRCount = 20 + maxPRCount = 50 ) func expandTilde(path string) string { diff --git a/cmd/help.go b/cmd/help.go index a9c746f..05e6629 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -1,7 +1,7 @@ package cmd var ( - helpText = `prs lets you stay updated on the PRs you care about without leaving the terminal. + helpText = `prs lets you stay updated on pull requests from the terminal. Usage: prs [flags] ` diff --git a/cmd/root.go b/cmd/root.go index 6b9439b..20e662c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,14 +59,12 @@ func Execute() { } if config.Query != nil { + if strings.Contains(*config.Query, "is:issue") || strings.Contains(*config.Query, "is: issue") { + die("is:issue cannot be used in the query") + } if strings.Contains(*config.Query, "type:issue") || strings.Contains(*config.Query, "type: issue") { die("type:issue cannot be used in the query") } - - if !strings.Contains(*config.Query, "type:pr") && !strings.Contains(*config.Query, "type: pr") { - updatedQuery := fmt.Sprintf("type: pr %s", *config.Query) - config.Query = &updatedQuery - } } var mode ui.Mode @@ -88,14 +86,14 @@ func Execute() { } if mode == ui.QueryMode && config.Query == nil { - sampleQuery := "is:pr repo:neovim/neovim sort:updated-desc" + sampleQuery := "is:pr author:@me sort:updated-desc state:open" config.Query = &sampleQuery } opts := ghapi.ClientOptions{ EnableCache: true, - CacheTTL: time.Minute * 1, - Timeout: 5 * time.Second, + CacheTTL: time.Second * 30, + Timeout: 8 * time.Second, } ghClient, err := ghapi.NewGraphQLClient(opts) diff --git a/cosign.pub b/cosign.pub new file mode 100644 index 0000000..c0ad83f --- /dev/null +++ b/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdDBCZ4oEQo3OXXuWYk9rFcJIMLf6 +bPuAsJHlkUoXLGqsyQnR9PP/ZyYUgOftnMulcPKtNFf+/GxT7ZQG9S/pBw== +-----END PUBLIC KEY----- diff --git a/internal/utils/assets/gruvbox.json b/internal/utils/assets/gruvbox.json index 0d938c3..5d348ad 100644 --- a/internal/utils/assets/gruvbox.json +++ b/internal/utils/assets/gruvbox.json @@ -21,7 +21,7 @@ "h1": { "prefix": "# ", "suffix": "", - "color": "#fe8019", + "color": "#d3869b", "bold": true }, "h2": { @@ -29,7 +29,7 @@ "color": "#83a598" }, "h3": { - "prefix": "", + "prefix": "### ", "color": "#83a598" }, "h4": { @@ -51,7 +51,9 @@ }, "emph": { "color": "#83a598", - "italic": true + "italic": true, + "prefix": " ", + "suffix": " " }, "strong": { "color": "#fe8019", diff --git a/ui/assets/help.md b/ui/assets/help.md index 6a97ac7..11db8fa 100644 --- a/ui/assets/help.md +++ b/ui/assets/help.md @@ -4,12 +4,13 @@ ## Views -prs has 5 views: +prs has 6 views: - PR List View +- PR Details View - PR Timeline List View -- PR Review Comments View -- Repo List View (only applicable when -mode=repos) +- PR Timeline Item Detail View +- Repo List View (only applicable when --mode=repos) - Help View (this one) ## Keyboard Shortcuts @@ -17,20 +18,11 @@ prs has 5 views: ### General ```text - tab Switch focus between PR List and PR Timeline Pane - 1 Switch focus to PR List View - 2 Switch focus to PR Timeline List View - 3 Switch focus to PR Review Comments View - ctrl+s Switch focus to Repo List View - ? Switch focus to Help View -``` - -### PR List/Timeline List View - - -```text - ctrl+v Show PR details - ctrl+d Show PR diff + q/esc/ctrl+c go back + Q quit from anywhere + ? Open Help View + d Open PR Details View + ctrl+v Show PR details using gh ``` ### PR List View @@ -42,16 +34,42 @@ prs has 5 views: šŸŸ” implies REVIEW_REQUIRED āœ… implies APPROVED - ctrl+b Open PR in the browser + āŽ/tab/shift+tab/2 Switch focus to PR Timeline View + ctrl+s Switch focus to Repo List View (when --mode=repos) + ctrl+d Show PR diff ctrl+r Reload PR list - enter Switch focus to PR Timeline View for currently selected PR - enter Show commit/revision range + ctrl+b Open PR in browser +``` + +### PR Details View + +```text + h/N/ā† Go to previous section + l/n/ā†’ Go to next section + 1/2/3... Go to specific section + J/] Go to next PR + K/[ Go to previous PR + d Go back to last view + ctrl+b Open PR in browser +``` + +### Timeline List View + + +```text + tab/shift+tab/1 Switch focus to PR List View + āŽ/3 Show details for PR timeline item (when applicable) + ctrl+d Show PR diff + ctrl+b Open timeline item in browser + ctrl+r Reload PR timeline ``` -### PR Timeline View +### Timeline Item Detail View + ```text + 1 Switch focus to PR List View + 2 Switch focus to PR Timeline List View + ctrl+d Show PR diff ctrl+b Open timeline item in browser - ctrl+r Reload timeline list - enter Switch focus to Review Comments View for currently selected item ``` diff --git a/ui/cmds.go b/ui/cmds.go index 331a573..3525a3f 100644 --- a/ui/cmds.go +++ b/ui/cmds.go @@ -25,12 +25,10 @@ func openURLInBrowser(url string) tea.Cmd { openCmd = "xdg-open" } c := exec.Command(openCmd, url) - return tea.ExecProcess(c, func(err error) tea.Msg { - if err != nil { - return urlOpenedinBrowserMsg{url: url, err: err} - } - return tea.Msg(urlOpenedinBrowserMsg{url: url}) - }) + err := c.Run() + return func() tea.Msg { + return urlOpenedinBrowserMsg{url: url, err: err} + } } func showDiff(repoOwner, repoName string, prNumber int, pager *string) tea.Cmd { @@ -113,6 +111,13 @@ func fetchAuthoredPRs(ghClient *ghapi.GraphQLClient, authorLogin string, prCount } } +func fetchPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner, repoName string, prNumber int) tea.Cmd { + return func() tea.Msg { + metadata, err := getPRMetadata(ghClient, repoOwner, repoName, prNumber) + return prMetadataFetchedMsg{repoOwner, repoName, prNumber, metadata, err} + } +} + func fetchPRTLItems(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int, tlItemsCount int, setItems bool) tea.Cmd { return func() tea.Msg { prTLItems, err := getPRTLData(ghClient, repoOwner, repoName, prNumber, tlItemsCount) diff --git a/ui/colors.go b/ui/colors.go index 545ce36..f50c3d7 100644 --- a/ui/colors.go +++ b/ui/colors.go @@ -2,190 +2,157 @@ package ui var ( colors = []string{ - "#00bbf9", - "#00f5d4", - "#06d6a0", - "#2ec4b6", - "#34a0a4", - "#34ace0", - "#43aa8b", - "#4895ef", - "#48bfe3", + "#fe77a4", + "#d3869a", + "#ff4c8b", + "#ffb0c2", + "#df748b", + "#ff6682", + "#f19597", + "#d89e9d", + "#fc5260", + "#e96462", + "#ffb5a2", + "#febcac", + "#f0947b", + "#ff6334", + "#af9084", + "#ff5405", + "#e98658", + "#be876e", + "#ff803b", + "#fd780b", + "#ff9743", + "#e2ac85", + "#d67717", + "#d4925c", + "#ffb472", + "#fe9103", + "#de9644", + "#dc8b00", + "#ffb13c", + "#c9b094", + "#faca7d", + "#c7921f", + "#c6a267", + "#d3cdc5", + "#fabd2f", + "#dcad50", + "#daa402", + "#ffc20c", + "#fbcf56", + "#b29807", + "#e7c727", + "#c7b648", + "#9c9360", + "#cec48b", + "#bbb206", + "#ddd601", + "#d1cc74", + "#b8bb26", + "#acaa5e", + "#b4c800", + "#a6b92b", + "#a8b64c", + "#aab08a", + "#849843", + "#a8d906", + "#a8a9a3", + "#88b500", + "#add562", + "#a0d845", + "#8de107", + "#829b60", + "#7db839", + "#94bc63", + "#71c200", + "#b5d092", + "#6e9f3a", + "#51a100", + "#b5e48c", + "#8ce852", + "#59d412", + "#89d967", + "#59c435", + "#4ba539", + "#00b700", + "#00db04", + "#9ae089", + "#6fbd63", + "#83b87a", + "#5ddb63", + "#04eb4d", + "#7a9879", + "#00ce48", + "#05b64c", + "#9cdea5", + "#64d97f", + "#8fbc96", + "#4daa67", + "#00d977", + "#12b667", + "#6ed999", + "#63bd8f", + "#00d990", + "#a7e0c2", + "#0abe88", + "#90b4a6", + "#83a598", + "#5cab95", + "#b9d9cf", + "#03d7b3", + "#00b499", + "#6fd0bd", + "#1edacd", + "#19b7b2", + "#89cbce", + "#4dcfdb", + "#62a6ae", + "#90e1ef", + "#01aac0", "#48cae4", - "#4cc9f0", - "#4ecdc4", - "#52b69a", - "#56cfe1", - "#64dfdf", - "#6d9dc5", - "#72efdd", - "#73d2de", - "#74c69d", - "#76c893", - "#80ffdb", + "#00ddff", + "#6ac1db", + "#00c3f9", + "#99bbcd", + "#149ccd", + "#6da2c6", + "#7bcaff", + "#07b1fa", + "#b4d4fb", + "#629fdb", + "#5aaaff", + "#0798ff", + "#4896ef", + "#bbd1ff", + "#9fb9f0", + "#949aab", + "#7b8ad5", + "#8498fb", + "#aaaffc", "#8187dc", - "#81b29a", - "#83a598", - "#84a59d", - "#85a5cc", - "#89b0ae", - "#8aa399", - "#8ac926", - "#8d99ae", - "#8e7cc3", - "#90be6d", - "#90e0ef", - "#94d2bd", - "#98c1d9", - "#99d98c", - "#9a8c98", - "#9d8189", - "#a0c4ff", - "#a3a380", - "#a3c4bc", - "#a5d8ff", - "#a7c957", - "#a8dadc", "#ada7ff", - "#b0a8b9", - "#b0c4b1", - "#b1a7a6", - "#b3e283", - "#b5838d", - "#b5c99a", - "#b5e48c", - "#b7b7a4", - "#b8b5ff", - "#b8bb26", - "#b8e0d2", - "#b8e994", - "#bbd0ff", - "#bbf0f3", - "#bdb2ff", - "#c5c7c5", - "#c77dff", - "#c7f9cc", - "#c9ada7", - "#ca7df9", - "#caffbf", - "#cbf3f0", - "#d3869b", - "#d4a5a5", - "#d6a2e8", - "#d6d4e0", - "#d8a48f", - "#d8d8d8", - "#d8e2dc", - "#d8f3dc", - "#d9ed92", - "#dad2bc", - "#e07a5f", - "#e0aaff", - "#e0fbfc", - "#e29578", - "#e2afff", - "#e36414", - "#e56b6f", - "#e5989b", - "#e76f51", - "#e9c46a", - "#e9d8a6", - "#e9ff70", - "#eaac8b", - "#eca1a6", - "#ee6c4d", - "#ee9b00", - "#ef9a9a", - "#f07167", - "#f08080", - "#f0b27a", - "#f0f3f4", - "#f15bb5", - "#f1948a", - "#f1c40f", - "#f1faee", - "#f28482", - "#f2cc8f", - "#f3722c", - "#f39c12", - "#f3ae73", - "#f4a261", - "#f4acb7", - "#f4f1de", - "#f5b041", - "#f5b5fc", - "#f5cac3", - "#f6bc1a", - "#f6bd60", - "#f6c4e1", - "#f6c66f", - "#f6e27f", - "#f6f7d7", - "#f77f00", - "#f7d6e0", - "#f7ede2", - "#f8961e", - "#f8edeb", - "#f9bc8f", - "#f9c74f", - "#f9cb9c", - "#f9dcc4", - "#fabd2f", - "#fb8500", - "#fb8b24", - "#fcbf49", - "#fcd5ce", - "#fdfcdc", - "#fdffb6", - "#fec89a", - "#fed9b7", - "#fee440", - "#ff595e", - "#ff5d8f", - "#ff6b6b", - "#ff6d00", - "#ff6f61", - "#ff8552", - "#ff9f1c", - "#ffa69e", - "#ffadad", - "#ffafcc", - "#ffb3c6", - "#ffb4a2", - "#ffb5a7", - "#ffb6b9", - "#ffb703", - "#ffba08", - "#ffbc42", - "#ffbe0b", - "#ffbf69", + "#aba3ca", + "#d2c8f2", + "#a681fb", + "#b798f0", + "#c3a4e1", + "#ce8cf7", + "#c97df9", + "#e6a5f4", + "#e47cfb", "#ffc6ff", - "#ffca3a", - "#ffcad4", - "#ffcbf2", - "#ffcdb2", - "#ffcf56", - "#ffd100", - "#ffd166", - "#ffd200", - "#ffd6a5", - "#ffd6e0", - "#ffd700", - "#ffd9da", - "#ffddc1", - "#ffe0b2", - "#ffe2e2", - "#ffe5d9", - "#ffe66d", - "#ffea00", - "#ffecd1", - "#ffee93", - "#fff0f3", - "#fff3b0", - "#fff7d6", - "#fff8e8", - "#fffae3", - "#fffcf2", - "#fffffc", + "#f344ff", + "#a882a7", + "#c57fbf", + "#ff4ded", + "#f081de", + "#fc69e6", + "#dfa5ca", + "#f646c1", + "#ceb4c3", + "#f27abe", + "#ae8c99", + "#ee91b6", } ) diff --git a/ui/gh.go b/ui/gh.go index 5208e8b..452b352 100644 --- a/ui/gh.go +++ b/ui/gh.go @@ -1,6 +1,8 @@ package ui import ( + "log" + ghapi "github.com/cli/go-gh/v2/pkg/api" ghgql "github.com/cli/shurcooL-graphql" ) @@ -26,6 +28,32 @@ func getPRDataFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, prCount return prs, nil } +func getPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int) (prDetails, error) { + var query prDetailsQuery + + variables := map[string]interface{}{ + "repositoryOwner": ghgql.String(repoOwner), + "repositoryName": ghgql.String(repoName), + "pullRequestNumber": ghgql.Int(prNumber), + "reviewRequestsCount": ghgql.Int(reviewRequestsCount), + "latestReviewsCount": ghgql.Int(latestReviewsCount), + "filesCount": ghgql.Int(filesCount), + "labelsCount": ghgql.Int(labelsCount), + "assigneesCount": ghgql.Int(assigneesCount), + "issuesCount": ghgql.Int(issuesCount), + "participantsCount": ghgql.Int(participantsCount), + "commentsCount": ghgql.Int(commentsCount), + "commitsCount": ghgql.Int(commitsCount), + "statusCheckContextsCount": ghgql.Int(statusCheckContextsCount), + } + err := ghClient.Query("PRTL", &query, variables) + if err != nil { + log.Printf("error: %s\n", err) + return prDetails{}, err + } + return query.RepositoryOwner.Repository.PullRequest, nil +} + func getViewerLoginData(ghClient *ghapi.GraphQLClient) (string, error) { var query userLoginQuery diff --git a/ui/initial.go b/ui/initial.go index 41b7c05..2eb83b1 100644 --- a/ui/initial.go +++ b/ui/initial.go @@ -19,17 +19,22 @@ func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) model prListDel := newPRListItemDel() prTLListDel := newPRTLListItemDel() + prDetailsCache := make(map[string]prDetails) prTLCache := make(map[string][]*prTLItemResult) + prDetailsCurSectionCache := make(map[string]uint) + m := model{ - mode: mode, - config: config, - ghClient: ghClient, - prsList: list.New(nil, prListDel, 0, 0), - prTLList: list.New(nil, prTLListDel, 0, 0), - prTLCache: prTLCache, - showHelp: true, - terminalDetails: terminalDetails{width: widthBudgetDefault}, + mode: mode, + config: config, + ghClient: ghClient, + prsList: list.New(nil, prListDel, 0, 0), + prTLList: list.New(nil, prTLListDel, 0, 0), + prDetailsCache: prDetailsCache, + prTLCache: prTLCache, + showHelp: true, + terminalDetails: terminalDetails{width: widthBudgetDefault}, + prDetailsCurSectionCache: prDetailsCurSectionCache, } switch m.mode { @@ -43,8 +48,10 @@ func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) model m.repoList.Styles.Title = m.repoList.Styles.Title.Background(lipgloss.Color(repoListColor)). Foreground(lipgloss.Color(defaultBackgroundColor)). Bold(true) + m.repoList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") + m.repoList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") case QueryMode, ReviewerMode, AuthorMode: - m.activePane = prList + m.activePane = prListView } m.prsList.Title = "fetching PRs..." @@ -55,6 +62,8 @@ func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) model m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor)). Foreground(lipgloss.Color(defaultBackgroundColor)). Bold(true) + m.prsList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") + m.prsList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") m.prTLList.Title = "fetching timeline..." m.prTLList.SetStatusBarItemName("item", "items") @@ -64,6 +73,8 @@ func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) model m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor)). Foreground(lipgloss.Color(defaultBackgroundColor)). Bold(true) + m.prTLList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") + m.prTLList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") return m } diff --git a/ui/model.go b/ui/model.go index 702405f..7b7c175 100644 --- a/ui/model.go +++ b/ui/model.go @@ -13,11 +13,12 @@ import ( type Pane uint const ( - repoList Pane = iota - prList - reviewPRList - prTLList - prRevCmts + repoListView Pane = iota + prListView + prDetailsView + reviewPRListView + prTLListView + prTLItemDetailView helpView ) @@ -31,28 +32,37 @@ const ( ) type model struct { - mode Mode - config Config - ghClient *ghapi.GraphQLClient - repoOwner string - repoName string - repoList list.Model - prsList list.Model - prTLList list.Model - prCache []*prResult - prRevCmtVP viewport.Model - prRevCmtVPReady bool - prTLCache map[string][]*prTLItemResult - message string - helpVP viewport.Model - helpVPReady bool - activePane Pane - lastPane Pane - showHelp bool - repoChosen bool - userLogin string - terminalDetails terminalDetails - mdRenderer *glamour.TermRenderer + mode Mode + config Config + ghClient *ghapi.GraphQLClient + repoOwner string + repoName string + repoList list.Model + prsList list.Model + prTLList list.Model + prCache []*prResult + prTLItemDetailVP viewport.Model + prTLItemDetailVPReady bool + prDetailsTitle string + prTLItemDetailTitle string + prDetailsVP viewport.Model + prDetailsVPReady bool + prDetailsCache map[string]prDetails + prTLCache map[string][]*prTLItemResult + message string + helpVP viewport.Model + helpVPReady bool + activePane Pane + lastPane Pane + secondLastActivePane Pane + showHelp bool + repoChosen bool + userLogin string + terminalDetails terminalDetails + mdRenderer *glamour.TermRenderer + prDetailsCurrentSection uint + prDetailsCurSectionCache map[string]uint + prRevCurCmtNum uint } func (m model) Init() tea.Cmd { diff --git a/ui/msgs.go b/ui/msgs.go index babb26d..508e081 100644 --- a/ui/msgs.go +++ b/ui/msgs.go @@ -11,6 +11,14 @@ type prsFetchedMsg struct { err error } +type prMetadataFetchedMsg struct { + repoOwner string + repoName string + prNumber int + metadata prDetails + err error +} + type reviewPRsFetchedMsg prsFetchedMsg type authoredPRsFetchedMsg prsFetchedMsg diff --git a/ui/navigation.go b/ui/navigation.go new file mode 100644 index 0000000..8464016 --- /dev/null +++ b/ui/navigation.go @@ -0,0 +1,160 @@ +package ui + +import ( + "fmt" + "strings" +) + +const ( + maxCommentsForNavIndicator = 8 +) + +func (m *model) setPRDetailsContent(prDetails prDetails, section PRDetailSection) { + content := fmt.Sprintf(`# %s (%s/%s/pull/%d) +`, prDetails.PRTitle, prDetails.Repository.Owner.Login, prDetails.Repository.Name, prDetails.Number, + ) + + switch section { + case PRMetadata: + content += prDetails.Metadata() + case PRDescription: + content += prDetails.Description() + case PRChecks: + content += prDetails.Checks() + case PRReferences: + content += prDetails.References() + case PRFilesChanged: + content += prDetails.FilesChanged() + case PRCommits: + content += prDetails.CommitsList() + case PRComments: + content += prDetails.CommentsList() + } + + glErr := true + if m.mdRenderer != nil { + contentGl, err := m.mdRenderer.Render(content) + if err == nil { + m.prDetailsVP.SetContent(contentGl) + glErr = false + } + } + if glErr { + m.prDetailsVP.SetContent(content) + } + + sections := make([]string, len(PRDetailsSectionList)) + for i := 0; i < len(PRDetailsSectionList); i++ { + sections[i] = "ā—Æ" + } + + if prDetails.Body == "" { + sections[PRDescription] = "ā—Œ" + } + // not foolproof, but should work in most cases + // func (pr prDetails) Checks() will return with an appropriate message in + // that case + if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) { + sections[PRChecks] = "ā—Œ" + } + if len(prDetails.IssueReferences.Nodes) == 0 { + sections[PRReferences] = "ā—Œ" + } + if len(prDetails.Files.Nodes) == 0 { + sections[PRFilesChanged] = "ā—Œ" + } + if len(prDetails.Commits.Nodes) == 0 { + sections[PRCommits] = "ā—Œ" + } + if len(prDetails.Comments.Nodes) == 0 { + sections[PRComments] = "ā—Œ" + } + + sections[section] = "ā—" + + m.prDetailsTitle = fmt.Sprintf("PR Details%s", " "+strings.Join(sections, " ")) + + m.prDetailsVP.GotoTop() +} + +func (m *model) GoToPRDetailSection(section uint) { + if m.prDetailsCurrentSection == section { + return + } + pr, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + return + } + + prDetails, ok := m.prDetailsCache[fmt.Sprintf("%s/%s:%d", pr.pr.Repository.Owner.Login, pr.pr.Repository.Name, pr.pr.Number)] + if !ok { + return + } + switch section { + case 1: + if prDetails.Body == "" { + return + } + case 2: + if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) { + return + } + case 3: + if len(prDetails.IssueReferences.Nodes) == 0 { + return + } + case 4: + if len(prDetails.Files.Nodes) == 0 { + return + } + case 5: + if len(prDetails.Commits.Nodes) == 0 { + return + } + case 6: + if len(prDetails.Comments.Nodes) == 0 { + return + } + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[section]) + m.prDetailsCurrentSection = section +} + +func (m *model) setPRReviewCmt(tlItem *prTLItem, commentNum uint) { + revCmts := tlItem.PullRequestReview.Comments.Nodes + var sectionsStr string + + if len(revCmts) > maxCommentsForNavIndicator { + sectionsStr = fmt.Sprintf("%d/%d", commentNum+1, len(revCmts)) + } else if len(revCmts) > 1 { + sections := make([]string, len(revCmts)) + for i := 0; i < len(revCmts); i++ { + sections[i] = "ā—Æ" + } + sections[commentNum] = "ā—" + sectionsStr = " " + strings.Join(sections, " ") + } + + var outdated string + if revCmts[commentNum].Outdated { + outdated = " `(outdated)`" + } + + content := fmt.Sprintf("# from @%s\n## %s%s\n%s\n```diff\n%s\n```", tlItem.PullRequestReview.Author.Login, revCmts[commentNum].Path, outdated, revCmts[commentNum].Body, revCmts[commentNum].DiffHunk) + + glErr := true + if m.mdRenderer != nil { + contentGl, err := m.mdRenderer.Render(content) + if err == nil { + m.prTLItemDetailVP.SetContent(contentGl) + glErr = false + } + } + if glErr { + m.prDetailsVP.SetContent(content) + } + + m.prTLItemDetailTitle = fmt.Sprintf("Review Comments%s", sectionsStr) + m.prTLItemDetailVP.GotoTop() +} diff --git a/ui/render_helpers.go b/ui/render_helpers.go index 5033e7c..f28db2b 100644 --- a/ui/render_helpers.go +++ b/ui/render_helpers.go @@ -124,35 +124,38 @@ func getPRTLItemTitle(item *prTLItem) string { } else { title = fmt.Sprintf("%s pushed a commit", item.PullRequestCommit.Commit.Author.Name) } + case tlItemHeadRefForcePushed: actor := getDynamicStyle(item.HeadRefForcePushed.Actor.Login).Render(item.HeadRefForcePushed.Actor.Login) - beforeCommitHash := item.HeadRefForcePushed.BeforeCommit.Oid - afterCommitHash := item.HeadRefForcePushed.AfterCommit.Oid - if len(beforeCommitHash) >= commitHashLen { - beforeCommitHash = beforeCommitHash[:commitHashLen] - } - if len(afterCommitHash) >= commitHashLen { - afterCommitHash = afterCommitHash[:commitHashLen] - } + beforeCommitHash := item.HeadRefForcePushed.BeforeCommit.AbbreviatedOid + afterCommitHash := item.HeadRefForcePushed.AfterCommit.AbbreviatedOid date = dateStyle.Render(humanize.Time(item.HeadRefForcePushed.CreatedAt)) title = fmt.Sprintf("%sforce pushed head ref from %s to %s%s", actor, beforeCommitHash, afterCommitHash, date) + case tlItemPRReadyForReview: actor := getDynamicStyle(item.PullRequestReadyForReview.Actor.Login).Render(item.PullRequestReadyForReview.Actor.Login) title = fmt.Sprintf("%smarked PR as ready for review", actor) + case tlItemPRReviewRequested: actor := getDynamicStyle(item.PullRequestReviewRequested.Actor.Login).Render(item.PullRequestReviewRequested.Actor.Login) reviewer := getDynamicStyle(item.PullRequestReviewRequested.RequestedReviewer.User.Login).Render(item.PullRequestReviewRequested.RequestedReviewer.User.Login) title = fmt.Sprintf("%srequested a review from %s", actor, reviewer) + case tlItemPRReview: author := getDynamicStyle(item.PullRequestReview.Author.Login).Render(item.PullRequestReview.Author.Login) date = dateStyle.Render(humanize.Time(item.PullRequestReview.CreatedAt)) var comments string + var more string + if item.PullRequestReview.Comments.TotalCount > 0 { + more = " āŽ" + } if item.PullRequestReview.Comments.TotalCount > 1 { comments = numCommentsStyle.Render(fmt.Sprintf("with %d comments", item.PullRequestReview.Comments.TotalCount)) } else if item.PullRequestReview.Comments.TotalCount == 1 { comments = numCommentsStyle.Render("with 1 comment") } - title = fmt.Sprintf("%sreviewed%s%s", author, comments, date) + title = fmt.Sprintf("%sreviewed%s%s%s", author, comments, date, more) + case tlItemMergedEvent: author := getDynamicStyle(item.MergedEvent.Actor.Login).Render(item.MergedEvent.Actor.Login) date = dateStyle.Render(humanize.Time(item.MergedEvent.CreatedAt)) diff --git a/ui/styles.go b/ui/styles.go index c6ddf62..45c6c56 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -11,6 +11,7 @@ const ( repoListColor = "#b8bb26" prListColor = "#fe8019" prTLListColor = "#d3869b" + prDetailsTitleColor = "#fabd2f" revCmtListColor = "#8ec07c" prOpenColor = "#fabd2f" prMergedColor = "#b8bb26" @@ -64,7 +65,6 @@ var ( viewPortStyle = lipgloss.NewStyle(). PaddingTop(1). - PaddingRight(2). PaddingBottom(1) helpMsgStyle = baseStyle. @@ -147,4 +147,7 @@ var ( helpVPTitleStyle = titleStyle. Background(lipgloss.Color(helpViewTitleColor)) + + prDetailsTitleStyle = titleStyle. + Background(lipgloss.Color(prDetailsTitleColor)) ) diff --git a/ui/types.go b/ui/types.go index bf26401..a64f948 100644 --- a/ui/types.go +++ b/ui/types.go @@ -2,29 +2,55 @@ package ui import ( "fmt" + "strings" "time" + + "github.com/dustin/go-humanize" ) const ( - prStateOpen = "OPEN" - prStateMerged = "MERGED" - prStateClosed = "CLOSED" - prRevDecChangesReq = "CHANGES_REQUESTED" - prRevDecApproved = "APPROVED" - prRevDecRevReq = "REVIEW_REQUIRED" - tlItemPRCommit = "PullRequestCommit" - tlItemPRReadyForReview = "ReadyForReviewEvent" - tlItemPRReviewRequested = "ReviewRequestedEvent" - tlItemPRReview = "PullRequestReview" - tlItemMergedEvent = "MergedEvent" - tlItemHeadRefForcePushed = "HeadRefForcePushedEvent" - reviewPending = "PENDING" - reviewCommented = "COMMENTED" - reviewApproved = "APPROVED" - reviewChangesRequested = "CHANGES_REQUESTED" - reviewDismissed = "DISMISSED" - - commitHashLen = 7 + prStateOpen = "OPEN" + prStateMerged = "MERGED" + prStateClosed = "CLOSED" + prRevDecChangesReq = "CHANGES_REQUESTED" + prRevDecApproved = "APPROVED" + prRevDecRevReq = "REVIEW_REQUIRED" + tlItemPRCommit = "PullRequestCommit" + tlItemPRReadyForReview = "ReadyForReviewEvent" + tlItemPRReviewRequested = "ReviewRequestedEvent" + tlItemPRReview = "PullRequestReview" + tlItemMergedEvent = "MergedEvent" + tlItemHeadRefForcePushed = "HeadRefForcePushedEvent" + reviewPending = "PENDING" + reviewCommented = "COMMENTED" + reviewApproved = "APPROVED" + reviewChangesRequested = "CHANGES_REQUESTED" + reviewDismissed = "DISMISSED" + checkStatusStateCompleted = "COMPLETED" + checkRunType = "CheckRun" + statusContextType = "StatusContext" + checkConclusionStateSuccess = "SUCCESS" + checkConclusionStateFailure = "FAILURE" + checkConclusionStateError = "ERROR" + statusStateSuccess = "SUCCESS" + statusStateFailure = "FAILURE" + statusStateError = "ERROR" + requestedReviewerUser = "User" + prDetailsMetadataKeyPadding = 30 + checkNamePadding = 40 + statusConclusionPadding = 16 + reviewRequestsCount = 20 + latestReviewsCount = 30 + filesCount = 50 + labelsCount = 10 + assigneesCount = 10 + issuesCount = 10 + participantsCount = 30 + commentsCount = 10 + commitsCount = 30 + statusCheckContextsCount = 50 + timeFormat = "2006/01/02 15:04" + mergeableConflicting = "CONFLICTING" ) type terminalDetails struct { @@ -59,6 +85,7 @@ type prResult struct { pr *pr title string description string + identifier string } type prTLItemResult struct { @@ -77,10 +104,14 @@ type pr struct { Name string } State string + Mergeable string + IsDraft bool ReviewDecision *string CreatedAt time.Time UpdatedAt time.Time - ClosedAt string + ClosedAt *time.Time + MergedAt *time.Time + LastEditedAt *time.Time Author struct { Login string } @@ -92,6 +123,154 @@ type pr struct { } } +type prDetails struct { + Number int + PRTitle string `graphql:"prTitle: title"` + Repository struct { + Owner struct { + Login string + } + Name string + } + State string + Mergeable string + IsDraft bool + ReviewDecision *string + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time + LastEditedAt *time.Time + Author struct { + Login string + } + Additions int + Deletions int + ReviewRequests *struct { + Nodes []struct { + RequestedReviewer *struct { + Type string `graphql:"type: __typename"` + User struct { + Login string + } `graphql:"... on User "` + } + } + } `graphql:"reviewRequests (first:$reviewRequestsCount)"` + LatestReviews struct { + Nodes []struct { + Author struct { + Login string + } + State string + } + } `graphql:"latestReviews (last: $latestReviewsCount)"` + Body string + Files struct { + Nodes []struct { + Path string + Additions int + Deletions int + } + } `graphql:"files (first: $filesCount)"` + Labels struct { + Nodes []struct { + Name string + } + } `graphql:"labels (first: $labelsCount)"` + Assignees struct { + Nodes []struct { + Login string + } + } `graphql:"assignees (first: $assigneesCount)"` + IssueReferences struct { + Nodes []struct { + Number int + Title string + Url string + } + } `graphql:"closingIssuesReferences (first: $issuesCount)"` + Participants struct { + Nodes []struct { + Login string + } + } `graphql:"participants (first: $participantsCount)"` + Comments struct { + TotalCount int + Nodes []struct { + Body string + UpdatedAt time.Time + Author struct { + Login string + } + } + } `graphql:"comments (first: $commentsCount)"` + Commits struct { + TotalCount int + Nodes []struct { + Commit struct { + AbbreviatedOid string + MessageHeadline string + AuthoredDate time.Time + Author struct { + Name string + } + } + } + } `graphql:"commits (last: $commitsCount)"` + MergedBy *struct { + Login string + } + Milestone *struct { + Title string + } + LastCommit struct { + Nodes []struct { + Commit struct { + AbbreviatedOid string + StatusCheckRollup *struct { + Contexts struct { + Nodes []struct { + Type string `graphql:"type: __typename"` + CheckRun struct { + Status string + Conclusion *string + Name string + } `graphql:"... on CheckRun"` + StatusContext struct { + State string + Context string + } `graphql:"... on StatusContext"` + } + } `graphql:"contexts (first: $statusCheckContextsCount) "` + State string + } + } + } + } `graphql:"lastCommit: commits(last: 1)"` +} + +type PRDetailSection uint + +const ( + PRMetadata PRDetailSection = iota + PRDescription + PRChecks + PRReferences + PRFilesChanged + PRCommits + PRComments +) + +var PRDetailsSectionList = []PRDetailSection{ + PRMetadata, + PRDescription, + PRChecks, + PRReferences, + PRFilesChanged, + PRCommits, + PRComments, +} + type prReviewComment struct { CreatedAt time.Time Body string @@ -118,12 +297,19 @@ type prSearchQuery struct { } `graphql:"search(query: $query, type: ISSUE, first: $count)"` } +type prDetailsQuery struct { + RepositoryOwner struct { + Repository struct { + PullRequest prDetails `graphql:"pullRequest(number: $pullRequestNumber)"` + } `graphql:"repository(name: $repositoryName)"` + } `graphql:"repositoryOwner(login: $repositoryOwner)"` +} + type prTLItem struct { Type string `graphql:"type: __typename"` PullRequestCommit struct { Url string Commit struct { - Oid string CommittedDate time.Time MessageHeadline string Author struct { @@ -140,10 +326,10 @@ type prTLItem struct { Login string } BeforeCommit struct { - Oid string + AbbreviatedOid string } AfterCommit struct { - Oid string + AbbreviatedOid string Url string MessageHeadline string } @@ -173,7 +359,7 @@ type prTLItem struct { Comments struct { TotalCount int Nodes []prReviewComment - } `graphql:"comments(last: 100)"` + } `graphql:"comments(first: 100)"` Author struct { Login string } @@ -182,7 +368,6 @@ type prTLItem struct { CreatedAt time.Time Url string MergeCommit struct { - Oid string MessageHeadline string } `graphql:"mergeCommit: commit"` Actor struct { @@ -203,6 +388,319 @@ type prTLQuery struct { } `graphql:"repositoryOwner(login: $repositoryOwner)"` } +func (pr prDetails) Metadata() string { + var metadata []string + + metadata = append(metadata, fmt.Sprintf("- %s *%s*", + RightPadTrim("State", prDetailsMetadataKeyPadding), + pr.State, + )) + + metadata = append(metadata, fmt.Sprintf("- %s `@%s`", + RightPadTrim("Author", prDetailsMetadataKeyPadding), + pr.Author.Login, + )) + + if len(pr.Assignees.Nodes) > 0 { + assignees := make([]string, len(pr.Assignees.Nodes)) + for i, l := range pr.Assignees.Nodes { + assignees[i] = fmt.Sprintf("`@%s`", l.Login) + } + metadata = append(metadata, fmt.Sprintf("- %s %s", + RightPadTrim("Assignees", prDetailsMetadataKeyPadding), + strings.Join(assignees, ", "), + )) + } + + if len(pr.Participants.Nodes) > 0 { + participants := make([]string, len(pr.Participants.Nodes)) + for i, l := range pr.Participants.Nodes { + participants[i] = fmt.Sprintf("`@%s`", l.Login) + } + metadata = append(metadata, fmt.Sprintf("- %s %s", + RightPadTrim("Participants", prDetailsMetadataKeyPadding), + strings.Join(participants, ", "), + )) + } + + if pr.ReviewRequests != nil && len(pr.ReviewRequests.Nodes) > 0 { + var requested []string + for _, r := range pr.ReviewRequests.Nodes { + if r.RequestedReviewer.Type != requestedReviewerUser { + continue + } + requested = append(requested, fmt.Sprintf("`@%s`", r.RequestedReviewer.User.Login)) + } + + if len(requested) > 0 { + metadata = append(metadata, fmt.Sprintf("- %s %s", + RightPadTrim("Review requested from", prDetailsMetadataKeyPadding), + strings.Join(requested, ", "), + )) + } + } + + metadata = append(metadata, fmt.Sprintf("- %s %s (%s)", + RightPadTrim("Created at", prDetailsMetadataKeyPadding), + pr.CreatedAt.Format(timeFormat), + humanize.Time(pr.CreatedAt), + )) + if pr.LastEditedAt != nil && *pr.LastEditedAt != pr.CreatedAt { + metadata = append(metadata, fmt.Sprintf("- %s %s (%s)", + RightPadTrim("Last edited at", prDetailsMetadataKeyPadding), + pr.LastEditedAt.Format(timeFormat), + humanize.Time(*pr.LastEditedAt), + )) + } + + switch pr.State { + case prStateClosed: + if pr.ClosedAt != nil { + metadata = append(metadata, fmt.Sprintf("- %s %s (%s)", + RightPadTrim("Closed at", prDetailsMetadataKeyPadding), + pr.ClosedAt.Format(timeFormat), + humanize.Time(*pr.ClosedAt), + )) + } + case prStateMerged: + metadata = append(metadata, fmt.Sprintf("- %s %s (%s) by `@%s`", + RightPadTrim("Merged at", prDetailsMetadataKeyPadding), + pr.MergedAt.Format(timeFormat), + humanize.Time(*pr.MergedAt), + pr.MergedBy.Login, + )) + } + + if len(pr.Labels.Nodes) > 0 { + labels := make([]string, len(pr.Labels.Nodes)) + for i, l := range pr.Labels.Nodes { + labels[i] = fmt.Sprintf("*%s*", l.Name) + } + metadata = append(metadata, fmt.Sprintf("- %s %s", + RightPadTrim("Labels", prDetailsMetadataKeyPadding), + strings.Join(labels, " "), + )) + } + + if pr.Commits.TotalCount > 0 { + metadata = append(metadata, fmt.Sprintf("- %s %d", + RightPadTrim("Commits", prDetailsMetadataKeyPadding), + pr.Commits.TotalCount, + )) + } + + if pr.Comments.TotalCount > 0 { + metadata = append(metadata, fmt.Sprintf("- %s %d", + RightPadTrim("Comments", prDetailsMetadataKeyPadding), + pr.Comments.TotalCount, + )) + } + + if pr.IsDraft { + metadata = append(metadata, fmt.Sprintf("- %s `true`", + RightPadTrim("Is draft", + prDetailsMetadataKeyPadding), + )) + } + + if pr.Mergeable == mergeableConflicting { + metadata = append(metadata, fmt.Sprintf("- %s `true`", RightPadTrim("Has conflicts", + prDetailsMetadataKeyPadding), + )) + } + + if pr.Milestone != nil { + metadata = append(metadata, fmt.Sprintf("- %s %s", RightPadTrim("Milestone", + prDetailsMetadataKeyPadding), + pr.Milestone.Title, + )) + } + + if len(pr.LatestReviews.Nodes) > 0 { + reviews := make([]string, len(pr.LatestReviews.Nodes)) + + for i, r := range pr.LatestReviews.Nodes { + var state string + switch r.State { + case reviewPending: + state = "šŸŸ”" + case reviewCommented: + state = "šŸ’¬" + case reviewChangesRequested: + state = "šŸ”„" + case reviewApproved: + state = "āœ…" + case reviewDismissed: + state = "āŒ" + } + reviews[i] = fmt.Sprintf("`@%s` %s", r.Author.Login, state) + } + + metadata = append(metadata, "\n---\n") + + metadata = append(metadata, fmt.Sprintf("- %s %s", + RightPadTrim("Reviewed by", prDetailsMetadataKeyPadding), + strings.Join(reviews, ", "), + )) + } + + return fmt.Sprintf(` +## Metadata + +%s`, strings.Join(metadata, "\n")) +} + +func (pr prDetails) Description() string { + return fmt.Sprintf(` +## Description + +%s`, pr.Body) +} + +func (pr prDetails) Checks() string { + if len(pr.LastCommit.Nodes) == 0 { + return "## No Checks" + } + if pr.LastCommit.Nodes[0].Commit.StatusCheckRollup == nil { + return "## No Checks" + } + if len(pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes) == 0 { + return "## No Checks" + } + + var checks []string + for _, n := range pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { + switch n.Type { + case checkRunType: + checkName := RightPadTrim(n.CheckRun.Name, checkNamePadding) + if n.CheckRun.Conclusion != nil { + var conclusionMarker string + switch *n.CheckRun.Conclusion { + case checkConclusionStateSuccess: + conclusionMarker = " āœ…" + case checkConclusionStateFailure, checkConclusionStateError: + conclusionMarker = " āŒ" + } + checks = append(checks, fmt.Sprintf("- %s %s%s", + checkName, + RightPadTrim(fmt.Sprintf("`%s`", *n.CheckRun.Conclusion), statusConclusionPadding), + conclusionMarker, + )) + } else { + checks = append(checks, fmt.Sprintf("- %s %s", checkName, n.CheckRun.Status)) + } + case statusContextType: + var stateMarker string + switch n.StatusContext.State { + case statusStateSuccess: + stateMarker = " āœ…" + case statusStateFailure, statusStateError: + stateMarker = " āŒ" + } + checks = append(checks, fmt.Sprintf("- %s %s%s", + RightPadTrim(n.StatusContext.Context, checkNamePadding), + RightPadTrim(fmt.Sprintf("`%s`", n.StatusContext.State), statusConclusionPadding), + stateMarker, + )) + } + } + + if len(checks) == 0 { + return "## No Checks" + } + + return fmt.Sprintf(` +## Checks + +%s **%s** + +%s`, + RightPadTrim("> Status of latest commit", checkNamePadding+2), + pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.State, + strings.Join(checks, "\n")) +} + +func (pr prDetails) References() string { + issues := make([]string, len(pr.IssueReferences.Nodes)) + for i, iss := range pr.IssueReferences.Nodes { + issues[i] = fmt.Sprintf("- `#%d`: %s (%s)", iss.Number, iss.Title, iss.Url) + } + return fmt.Sprintf(` +## Referenced by + +%s`, strings.Join(issues, "\n")) +} + +func (pr prDetails) FilesChanged() string { + fc := make([]string, len(pr.Files.Nodes)) + for i, f := range pr.Files.Nodes { + var additions string + var deletions string + + if f.Additions > 0 { + additions = fmt.Sprintf(" `+%d`", f.Additions) + } + + if f.Deletions > 0 { + deletions = fmt.Sprintf(" `-%d`", f.Deletions) + } + + fc[i] = fmt.Sprintf("- %s%s%s", f.Path, additions, deletions) + } + return fmt.Sprintf(` +## Files changed + +%s`, strings.Join(fc, "\n")) +} + +func (pr prDetails) CommitsList() string { + var commitsStr string + + commits := make([]string, len(pr.Commits.Nodes)) + for i, c := range pr.Commits.Nodes { + hash := c.Commit.AbbreviatedOid + + commits[i] = fmt.Sprintf("- `%s`: %s **(%s)** `<%s>`", + hash, + c.Commit.MessageHeadline, + humanize.Time(c.Commit.AuthoredDate), + c.Commit.Author.Name, + ) + } + + var commitsNumStr string + if len(pr.Commits.Nodes) < pr.Commits.TotalCount { + commitsNumStr = fmt.Sprintf(" (last %d out of %d)", len(pr.Commits.Nodes), pr.Commits.TotalCount) + } + + commitsStr = fmt.Sprintf(` +## Commits%s + +%s +`, commitsNumStr, strings.Join(commits, "\n")) + + return commitsStr +} + +func (pr prDetails) CommentsList() string { + + comments := make([]string, len(pr.Comments.Nodes)) + for i, c := range pr.Comments.Nodes { + comments[i] = fmt.Sprintf("`@%s` (%s):\n\n%s", c.Author.Login, humanize.Time(c.UpdatedAt), c.Body) + } + + var commentsNumStr string + if len(pr.Comments.Nodes) < pr.Comments.TotalCount { + commentsNumStr = fmt.Sprintf(" (first %d out of %d)", len(pr.Comments.Nodes), pr.Comments.TotalCount) + } + + return fmt.Sprintf(` +## Comments%s + +%s +`, commentsNumStr, strings.Join(comments, "\n\nā–¬ā–¬ā–¬ā–¬ā–¬ā–¬\n\n")) +} + func (repo Repo) Title() string { return repo.Name } @@ -215,16 +713,16 @@ func (repo Repo) FilterValue() string { return fmt.Sprintf("%s:::%s", repo.Owner, repo.Name) } -func (pr prResult) Title() string { - return pr.title +func (prRes prResult) Title() string { + return prRes.title } -func (pr prResult) Description() string { - return pr.description +func (prRes prResult) Description() string { + return prRes.description } -func (pr prResult) FilterValue() string { - return fmt.Sprintf("%d", pr.pr.Number) +func (prRes prResult) FilterValue() string { + return fmt.Sprintf("%d", prRes.pr.Number) } func (ir prTLItemResult) Title() string { diff --git a/ui/update.go b/ui/update.go index dcc8770..25e0a6f 100644 --- a/ui/update.go +++ b/ui/update.go @@ -2,6 +2,7 @@ package ui import ( _ "embed" + "errors" "fmt" "strings" @@ -14,12 +15,14 @@ import ( const ( useHighPerformanceRenderer = false - viewPortMoveLineCount = 3 + viewPortMoveLineCount = 5 ) var ( //go:embed assets/help.md helpStr string + + ErrPRDetailsNotCached = errors.New("PR details were not saved") ) func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -33,28 +36,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "Q": return m, tea.Quit case "ctrl+c", "q", "esc": - if m.activePane == repoList { + switch m.activePane { + case repoListView: if !m.repoChosen { return m, tea.Quit } m.activePane = m.lastPane - } else if m.activePane == helpView { + case helpView: m.activePane = m.lastPane - } else if m.activePane == prRevCmts { - m.prRevCmtVP.GotoTop() - m.activePane = prTLList - } else if m.activePane == prTLList { + case prTLItemDetailView: + m.prTLItemDetailVP.GotoTop() + m.activePane = prTLListView + case prTLListView: m.prTLList.ResetSelected() - m.activePane = prList - } else if m.mode == RepoMode && m.activePane == prList { - m.activePane = repoList - m.repoChosen = false - } else { + m.activePane = prListView + case prDetailsView: + if m.lastPane == m.activePane { + m.activePane = m.secondLastActivePane + m.lastPane = prDetailsView + break + } + m.activePane = m.lastPane + m.lastPane = prDetailsView + case prListView: + if m.mode == RepoMode { + m.activePane = repoListView + m.repoChosen = false + break + } + return m, tea.Quit + default: return m, tea.Quit } + case "ctrl+r": switch m.activePane { - case prList: + case prListView: switch m.mode { case RepoMode: @@ -69,7 +86,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prsList.Title = "fetching PRs..." m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor)) - case prTLList: + case prTLListView: pr, ok := m.prsList.SelectedItem().(*prResult) if !ok { break @@ -83,12 +100,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(fetchingColor)) } case "1": - if m.activePane != prList { - m.activePane = prList + if m.activePane != prTLListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView { + break } + + switch m.activePane { + case prDetailsView: + m.GoToPRDetailSection(0) + default: + m.activePane = prListView + } + case "enter": switch m.activePane { - case prList: + case prListView: setTlCmd, ok := m.setTL() if !ok { m.message = "Could't get repo/pr details. Inform @dhth on github." @@ -97,63 +122,113 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, setTlCmd) } } - case prTLList: + case prTLListView: item, ok := m.prTLList.SelectedItem().(*prTLItemResult) - if ok { - if item.item.Type == tlItemPRReview { - revCmts := item.item.PullRequestReview.Comments.Nodes - if len(revCmts) == 0 { - break - } + if !ok { + break + } - m.setPRTLContent(revCmts) - m.activePane = prRevCmts - } + if item.item.Type != tlItemPRReview { + break } - case repoList: + + if len(item.item.PullRequestReview.Comments.Nodes) == 0 { + break + } + + m.setPRReviewCmt(item.item, 0) + m.prRevCurCmtNum = 0 + m.activePane = prTLItemDetailView + + case repoListView: selected := m.repoList.SelectedItem() if selected != nil { cmds = append(cmds, chooseRepo(selected.FilterValue())) } } case "2": - if m.activePane != prTLList { + if m.activePane != prListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView { + break + } + + switch m.activePane { + case prDetailsView: + m.GoToPRDetailSection(1) + default: setTlCmd, ok := m.setTL() if !ok { m.message = "Could't get repo/pr details. Inform @dhth on github." - } else { - if setTlCmd != nil { - cmds = append(cmds, setTlCmd) - } + break + } + + if setTlCmd != nil { + cmds = append(cmds, setTlCmd) } } + case "3": - if m.activePane == prTLList { - item, ok := m.prTLList.SelectedItem().(*prTLItemResult) - if ok { - if item.item.Type == tlItemPRReview { - revCmts := item.item.PullRequestReview.Comments.Nodes - if len(revCmts) == 0 { - break - } + if m.activePane != prTLListView && m.activePane != prDetailsView { + break + } - m.setPRTLContent(revCmts) - m.activePane = prRevCmts - } + switch m.activePane { + case prDetailsView: + m.GoToPRDetailSection(2) + default: + tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult) + if !ok { + break + } + + if tlItem.item.Type != tlItemPRReview { + break + } + + if len(tlItem.item.PullRequestReview.Comments.Nodes) == 0 { + break } + + m.setPRReviewCmt(tlItem.item, 0) + m.activePane = prTLItemDetailView + } + + case "4": + if m.activePane != prDetailsView { + break + } + + m.GoToPRDetailSection(3) + + case "5": + if m.activePane != prDetailsView { + break } + m.GoToPRDetailSection(4) + + case "6": + if m.activePane != prDetailsView { + break + } + + m.GoToPRDetailSection(5) + case "j", "down": - if m.activePane != prRevCmts && m.activePane != helpView { + if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView { break } switch m.activePane { - case prRevCmts: - if m.prRevCmtVP.AtBottom() { + case prTLItemDetailView: + if m.prTLItemDetailVP.AtBottom() { + break + } + m.prTLItemDetailVP.LineDown(viewPortMoveLineCount) + case prDetailsView: + if m.prDetailsVP.AtBottom() { break } - m.prRevCmtVP.LineDown(viewPortMoveLineCount) + m.prDetailsVP.LineDown(viewPortMoveLineCount) case helpView: if m.helpVP.AtBottom() { break @@ -162,16 +237,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "k", "up": - if m.activePane != prRevCmts && m.activePane != helpView { + if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView { break } switch m.activePane { - case prRevCmts: - if m.prRevCmtVP.AtTop() { + case prTLItemDetailView: + if m.prTLItemDetailVP.AtTop() { break } - m.prRevCmtVP.LineUp(viewPortMoveLineCount) + m.prTLItemDetailVP.LineUp(viewPortMoveLineCount) + case prDetailsView: + if m.prDetailsVP.AtTop() { + break + } + m.prDetailsVP.LineUp(viewPortMoveLineCount) case helpView: if m.helpVP.AtTop() { break @@ -180,11 +260,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "tab", "shift+tab": - if m.activePane == helpView { + if m.activePane == helpView || m.activePane == prDetailsView { break } - if m.activePane == prList { + if m.activePane == prListView { setTlCmd, ok := m.setTL() if !ok { m.message = "Could't get repo/pr details. Inform @dhth on github." @@ -194,51 +274,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } else { - m.activePane = prList + m.activePane = prListView } case "ctrl+s": if m.mode == RepoMode { - if m.activePane != repoList { + if m.activePane != repoListView { m.lastPane = m.activePane - m.activePane = repoList + m.activePane = repoListView } else { m.activePane = m.lastPane } } + case "ctrl+b": switch m.activePane { - case prList: - var url string - switch m.mode { - case RepoMode, QueryMode: - pr, ok := m.prsList.SelectedItem().(*prResult) - if ok { - url = pr.pr.Url - } - case ReviewerMode: - pr, ok := m.prsList.SelectedItem().(*prResult) - if ok { - url = pr.pr.Url - } + case prListView, prDetailsView: + pr, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break } - cmds = append(cmds, openURLInBrowser(url)) - case prTLList, prRevCmts: + + cmds = append(cmds, openURLInBrowser(pr.pr.Url)) + case prTLListView, prTLItemDetailView: item, ok := m.prTLList.SelectedItem().(*prTLItemResult) - if ok { - switch item.item.Type { - case tlItemPRCommit: - cmds = append(cmds, openURLInBrowser(item.item.PullRequestCommit.Url)) - case tlItemHeadRefForcePushed: - cmds = append(cmds, openURLInBrowser(item.item.HeadRefForcePushed.AfterCommit.Url)) - case tlItemPRReview: - cmds = append(cmds, openURLInBrowser(item.item.PullRequestReview.Url)) - case tlItemMergedEvent: - cmds = append(cmds, openURLInBrowser(item.item.MergedEvent.Url)) - } + if !ok { + break + } + + switch item.item.Type { + case tlItemPRCommit: + cmds = append(cmds, openURLInBrowser(item.item.PullRequestCommit.Url)) + case tlItemHeadRefForcePushed: + cmds = append(cmds, openURLInBrowser(item.item.HeadRefForcePushed.AfterCommit.Url)) + case tlItemPRReview: + cmds = append(cmds, openURLInBrowser(item.item.PullRequestReview.Url)) + case tlItemMergedEvent: + cmds = append(cmds, openURLInBrowser(item.item.MergedEvent.Url)) } } + case "ctrl+d": - if m.activePane != prList && m.activePane != prTLList { + if m.activePane != prListView && m.activePane != prTLListView && m.activePane != prTLItemDetailView { break } @@ -253,38 +329,332 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.config.DiffPager)) case "ctrl+v": - if m.activePane == prList || m.activePane == prTLList { - switch m.mode { - case RepoMode: - pr, ok := m.prsList.SelectedItem().(*prResult) - if ok { - cmds = append(cmds, showPR(m.repoOwner, m.repoName, pr.pr.Number)) + if m.activePane == helpView { + break + } + pr, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + cmds = append(cmds, showPR(pr.pr.Repository.Owner.Login, + pr.pr.Repository.Name, + pr.pr.Number)) + + case "g": + switch m.activePane { + case prTLItemDetailView: + m.prTLItemDetailVP.GotoTop() + case prDetailsView: + m.prDetailsVP.GotoTop() + case helpView: + m.helpVP.GotoTop() + } + case "G": + switch m.activePane { + case prTLItemDetailView: + m.prTLItemDetailVP.GotoBottom() + case prDetailsView: + m.prDetailsVP.GotoBottom() + case helpView: + m.helpVP.GotoBottom() + } + + case "K", "[": + if m.activePane != prDetailsView { + break + } + + m.prsList.CursorUp() + prRes, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + prDetails, ok := m.prDetailsCache[prRes.identifier] + if !ok { + break + } + + var section uint + lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier] + if ok { + section = lastSection + } else { + section = 0 + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[section]) + m.prDetailsCurrentSection = section + + case "J", "]": + if m.activePane != prDetailsView { + break + } + + m.prsList.CursorDown() + prRes, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + prDetails, ok := m.prDetailsCache[prRes.identifier] + if !ok { + break + } + + var section uint + lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier] + if ok { + section = lastSection + } else { + section = 0 + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[section]) + m.prDetailsCurrentSection = section + + case "d": + if m.activePane != prListView && m.activePane != prDetailsView && m.activePane != prTLListView && m.activePane != prTLItemDetailView { + break + } + + if m.activePane == prDetailsView { + m.activePane = m.lastPane + break + } + + prRes, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + prDetails, ok := m.prDetailsCache[prRes.identifier] + if !ok { + m.message = "PR details were not retrieved" + break + } + + var section uint + lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier] + if ok { + section = lastSection + } else { + section = 0 + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[section]) + m.prDetailsCurrentSection = section + + m.prDetailsVP.GotoTop() + m.lastPane = m.activePane + m.activePane = prDetailsView + + case "l", "n", "right": + if m.activePane != prDetailsView && m.activePane != prTLItemDetailView { + break + } + + switch m.activePane { + case prDetailsView: + prRes, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + prDetails, ok := m.prDetailsCache[prRes.identifier] + if !ok { + break + } + + nextSectionFound := false + var nextSection uint + if m.prDetailsCurrentSection == uint(len(PRDetailsSectionList)-1) { + nextSection = 0 + } else { + nextSection = m.prDetailsCurrentSection + 1 + } + + for { + switch nextSection { + case 0: + nextSectionFound = true + case 1: + if prDetails.Body != "" { + nextSectionFound = true + } + case 2: + // this may still lead to no status checks being shown + // but the probability of that happening is pretty low + if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil { + nextSectionFound = true + } + case 3: + if len(prDetails.IssueReferences.Nodes) > 0 { + nextSectionFound = true + } + case 4: + if len(prDetails.Files.Nodes) > 0 { + nextSectionFound = true + } + case 5: + if len(prDetails.Commits.Nodes) > 0 { + nextSectionFound = true + } + case 6: + if len(prDetails.Comments.Nodes) > 0 { + nextSectionFound = true + } else { + nextSection = 0 + nextSectionFound = true + } } - case ReviewerMode: - pr, ok := m.prsList.SelectedItem().(*prResult) - if ok { - cmds = append(cmds, showPR(pr.pr.Repository.Owner.Login, - pr.pr.Repository.Name, - pr.pr.Number)) + + if nextSectionFound { + break } + + nextSection += 1 + } + + if !nextSectionFound { + break + } + + if nextSection > uint(len(PRDetailsSectionList)-1) { + m.message = "Something went wrong" + break + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[nextSection]) + m.prDetailsCurSectionCache[prRes.identifier] = nextSection + m.prDetailsCurrentSection = nextSection + + case prTLItemDetailView: + tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult) + if !ok { + break } + + if tlItem.item.Type != tlItemPRReview { + break + } + + if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 { + break + } + + nextCommentIndex := m.prRevCurCmtNum + 1 + if nextCommentIndex > uint(len(tlItem.item.PullRequestReview.Comments.Nodes))-1 { + nextCommentIndex = 0 + } + + m.setPRReviewCmt(tlItem.item, nextCommentIndex) + m.prRevCurCmtNum = nextCommentIndex } - case "g": - if m.activePane == prRevCmts { - m.prRevCmtVP.GotoTop() + + case "h", "N", "left": + if m.activePane != prDetailsView && m.activePane != prTLItemDetailView { + break } - case "G": - if m.activePane == prRevCmts { - m.prRevCmtVP.GotoBottom() + + switch m.activePane { + case prDetailsView: + prRes, ok := m.prsList.SelectedItem().(*prResult) + if !ok { + break + } + + prDetails, ok := m.prDetailsCache[prRes.identifier] + if !ok { + break + } + + prevSectionFound := false + var prevSection uint + if m.prDetailsCurrentSection == 0 { + prevSection = uint(len(PRDetailsSectionList) - 1) + } else { + prevSection = m.prDetailsCurrentSection - 1 + } + + for { + switch prevSection { + case 0: + prevSectionFound = true + case 1: + if prDetails.Body != "" { + prevSectionFound = true + } + case 2: + if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil { + prevSectionFound = true + } + case 3: + if len(prDetails.IssueReferences.Nodes) > 0 { + prevSectionFound = true + } + case 4: + if len(prDetails.Files.Nodes) > 0 { + prevSectionFound = true + } + case 5: + if len(prDetails.Commits.Nodes) > 0 { + prevSectionFound = true + } + case 6: + if len(prDetails.Comments.Nodes) > 0 { + prevSectionFound = true + } + } + + if prevSectionFound { + break + } + + prevSection -= 1 + } + + m.setPRDetailsContent(prDetails, PRDetailsSectionList[prevSection]) + m.prDetailsCurSectionCache[prRes.identifier] = prevSection + m.prDetailsCurrentSection = prevSection + + case prTLItemDetailView: + tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult) + if !ok { + break + } + + if tlItem.item.Type != tlItemPRReview { + break + } + + if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 { + break + } + + var prevCommentIndex uint + if m.prRevCurCmtNum == 0 { + prevCommentIndex = uint(len(tlItem.item.PullRequestReview.Comments.Nodes) - 1) + } else { + prevCommentIndex = m.prRevCurCmtNum - 1 + } + + m.setPRReviewCmt(tlItem.item, prevCommentIndex) + m.prRevCurCmtNum = prevCommentIndex } + case "?": - switch m.activePane { - case helpView: + if m.activePane == helpView { m.activePane = m.lastPane - default: - m.lastPane = m.activePane - m.activePane = helpView + break + } + if m.activePane == prDetailsView { + m.secondLastActivePane = m.lastPane } + + m.lastPane = m.activePane + m.activePane = helpView } case hideHelpMsg: m.showHelp = false @@ -303,24 +673,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prTLList.SetHeight(msg.Height - h - 2) m.prTLList.SetWidth(msg.Width - w) - if !m.prRevCmtVPReady { - m.prRevCmtVP = viewport.New(msg.Width-2, msg.Height-7) - m.prRevCmtVP.HighPerformanceRendering = useHighPerformanceRenderer - m.prRevCmtVPReady = true - m.prRevCmtVP.KeyMap.HalfPageDown.SetKeys("ctrl+d") - m.prRevCmtVP.KeyMap.Up.SetEnabled(false) - m.prRevCmtVP.KeyMap.Down.SetEnabled(false) + if !m.prTLItemDetailVPReady { + m.prTLItemDetailVP = viewport.New(msg.Width-2, msg.Height-7) + m.prTLItemDetailVP.HighPerformanceRendering = useHighPerformanceRenderer + m.prTLItemDetailVPReady = true + m.prTLItemDetailVP.KeyMap.HalfPageDown.SetKeys("ctrl+d") + m.prTLItemDetailVP.KeyMap.Up.SetEnabled(false) + m.prTLItemDetailVP.KeyMap.Down.SetEnabled(false) } else { - m.prRevCmtVP.Width = msg.Width - 2 - m.prRevCmtVP.Height = msg.Height - 7 + m.prTLItemDetailVP.Width = msg.Width - 2 + m.prTLItemDetailVP.Height = msg.Height - 7 } - crWrap := (msg.Width - 4) - if crWrap > contextWordWrapUpperLimit { - crWrap = contextWordWrapUpperLimit + if !m.prDetailsVPReady { + m.prDetailsVP = viewport.New(msg.Width-2, msg.Height-7) + m.prDetailsVP.HighPerformanceRendering = useHighPerformanceRenderer + m.prDetailsVPReady = true + m.prDetailsVP.KeyMap.HalfPageDown.SetKeys("ctrl+d") + m.prDetailsVP.KeyMap.Up.SetEnabled(false) + m.prDetailsVP.KeyMap.Down.SetEnabled(false) + } else { + m.prDetailsVP.Width = msg.Width - 2 + m.prDetailsVP.Height = msg.Height - 7 } - m.mdRenderer, _ = utils.GetMarkDownRenderer(crWrap) + vpWrap := (msg.Width - 4) + if vpWrap > viewPortWrapUpperLimit { + vpWrap = viewPortWrapUpperLimit + } + + m.mdRenderer, _ = utils.GetMarkDownRenderer(vpWrap) helpToRender := helpStr switch m.mdRenderer { @@ -354,7 +736,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.prsList.SetItems(prs) - if m.activePane == prTLList { + if m.activePane == prTLListView { m.setTL() } @@ -368,7 +750,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor)) m.repoOwner = repoDetails[0] m.repoName = repoDetails[1] - m.activePane = prList + m.activePane = prListView m.prsList.ResetSelected() m.prTLList.ResetSelected() cmds = append(cmds, fetchPRSForRepo(m.ghClient, m.repoOwner, m.repoName, m.config.PRCount)) @@ -394,12 +776,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { prs := make([]list.Item, len(msg.prs)) prResults := make([]*prResult, len(msg.prs)) + m.prDetailsCurSectionCache = make(map[string]uint) for i, pr := range msg.prs { prResults[i] = &prResult{ pr: &pr, title: getPRTitle(&pr), description: getPRDesc(&pr, m.mode, m.terminalDetails), + identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), } prs[i] = prResults[i] } @@ -425,6 +809,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 100, false, )) + cmds = append(cmds, fetchPRMetadata(m.ghClient, + pr.Repository.Owner.Login, + pr.Repository.Name, + pr.Number, + )) } case reviewPRsFetchedMsg: @@ -434,14 +823,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } prs := make([]list.Item, len(msg.prs)) - prResults := make([]*prResult, len(msg.prs)) + m.prDetailsCurSectionCache = make(map[string]uint) for i, pr := range msg.prs { prResults[i] = &prResult{ pr: &pr, title: getPRTitle(&pr), description: getPRDesc(&pr, m.mode, m.terminalDetails), + identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), } prs[i] = prResults[i] } @@ -455,37 +845,58 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.prs) > 0 { for _, pr := range msg.prs { cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false)) + cmds = append(cmds, fetchPRMetadata(m.ghClient, + pr.Repository.Owner.Login, + pr.Repository.Name, + pr.Number, + )) } } case authoredPRsFetchedMsg: if msg.err != nil { m.message = msg.err.Error() - } else { - prs := make([]list.Item, len(msg.prs)) + break + } - prResults := make([]*prResult, len(msg.prs)) + prs := make([]list.Item, len(msg.prs)) + prResults := make([]*prResult, len(msg.prs)) + m.prDetailsCurSectionCache = make(map[string]uint) - for i, pr := range msg.prs { - prResults[i] = &prResult{ - pr: &pr, - title: getPRTitle(&pr), - description: getPRDesc(&pr, m.mode, m.terminalDetails), - } - prs[i] = prResults[i] + for i, pr := range msg.prs { + prResults[i] = &prResult{ + pr: &pr, + title: getPRTitle(&pr), + description: getPRDesc(&pr, m.mode, m.terminalDetails), + identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), } + prs[i] = prResults[i] + } - m.prCache = prResults - m.prsList.SetItems(prs) - m.prsList.Title = "Open PRs authored by you" - m.prsList.ResetSelected() - m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor)) + m.prCache = prResults + m.prsList.SetItems(prs) + m.prsList.Title = "Open PRs authored by you" + m.prsList.ResetSelected() + m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor)) - if len(msg.prs) > 0 { - for _, pr := range msg.prs { - cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false)) - } + if len(msg.prs) > 0 { + for _, pr := range msg.prs { + cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false)) + cmds = append(cmds, fetchPRMetadata(m.ghClient, + pr.Repository.Owner.Login, + pr.Repository.Name, + pr.Number, + )) } } + + case prMetadataFetchedMsg: + if msg.err != nil { + m.message = msg.err.Error() + break + } + + m.prDetailsCache[fmt.Sprintf("%s/%s:%d", msg.repoOwner, msg.repoName, msg.prNumber)] = msg.metadata + case prTLFetchedMsg: if msg.err != nil { m.message = msg.err.Error() @@ -511,7 +922,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.prTLList.SetItems(prTLItems) m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", msg.prNumber) m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor)) - m.activePane = prTLList + m.activePane = prTLListView } m.prTLList.ResetSelected() @@ -531,16 +942,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch m.activePane { - case prList: + case prListView: m.prsList, cmd = m.prsList.Update(msg) cmds = append(cmds, cmd) - case prTLList: + case prTLListView: m.prTLList, cmd = m.prTLList.Update(msg) cmds = append(cmds, cmd) - case prRevCmts: - m.prRevCmtVP, cmd = m.prRevCmtVP.Update(msg) + case prDetailsView: + m.prDetailsVP, cmd = m.prDetailsVP.Update(msg) + cmds = append(cmds, cmd) + case prTLItemDetailView: + m.prTLItemDetailVP, cmd = m.prTLItemDetailVP.Update(msg) cmds = append(cmds, cmd) - case repoList: + case repoListView: m.repoList, cmd = m.repoList.Update(msg) cmds = append(cmds, cmd) case helpView: @@ -556,16 +970,16 @@ func (m *model) setTL() (tea.Cmd, bool) { var repoOwner, repoName string var prNumber int - prItem, prOk := m.prsList.SelectedItem().(*prResult) + prRes, prOk := m.prsList.SelectedItem().(*prResult) if !prOk { return nil, false } - repoOwner = prItem.pr.Repository.Owner.Login - repoName = prItem.pr.Repository.Name - prNumber = prItem.pr.Number + repoOwner = prRes.pr.Repository.Owner.Login + repoName = prRes.pr.Repository.Name + prNumber = prRes.pr.Number - tlFromCache, ok := m.prTLCache[fmt.Sprintf("%s/%s:%d", repoOwner, repoName, prNumber)] + tlFromCache, ok := m.prTLCache[prRes.identifier] if !ok { cmd = fetchPRTLItems(m.ghClient, repoOwner, repoName, prNumber, 100, true) return cmd, true @@ -587,33 +1001,7 @@ func (m *model) setTL() (tea.Cmd, bool) { m.prTLList.SetItems(tlItems) m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", prNumber) - m.activePane = prTLList + m.activePane = prTLListView return nil, true } - -func (m *model) setPRTLContent(revCmts []prReviewComment) { - prReviewCmts := make([]string, len(revCmts)) - for i, cmt := range revCmts { - var outdated string - if cmt.Outdated { - outdated = " `(outdated)`" - } - - prReviewCmt := fmt.Sprintf("### %s%s\n%s\n```diff\n%s\n```", cmt.Path, outdated, cmt.Body, cmt.DiffHunk) - prReviewCmts[i] = prReviewCmt - } - - content := strings.Join(prReviewCmts, "\n---\n") - glErr := true - if m.mdRenderer != nil { - contentGl, err := m.mdRenderer.Render(content) - if err == nil { - m.prRevCmtVP.SetContent(contentGl) - glErr = false - } - } - if glErr { - m.prRevCmtVP.SetContent(content) - } -} diff --git a/ui/view.go b/ui/view.go index c851b3a..40c05b7 100644 --- a/ui/view.go +++ b/ui/view.go @@ -7,7 +7,7 @@ import ( ) const ( - contextWordWrapUpperLimit = 160 + viewPortWrapUpperLimit = 160 ) func (m model) View() string { @@ -20,20 +20,28 @@ func (m model) View() string { } switch m.activePane { - case prList: + case prListView: content = listStyle.Render(m.prsList.View()) - case prTLList: + case prTLListView: content = listStyle.Render(m.prTLList.View()) - case repoList: + case repoListView: content = listStyle.Render(m.repoList.View()) - case prRevCmts: + case prDetailsView: + if !m.prTLItemDetailVPReady { + content = "\n Initializing..." + } else { + content = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n", + prDetailsTitleStyle.Render(m.prDetailsTitle), + m.prDetailsVP.View())) + } + case prTLItemDetailView: var prRevCmtsVP string - if !m.prRevCmtVPReady { + if !m.prTLItemDetailVPReady { prRevCmtsVP = "\n Initializing..." } else { prRevCmtsVP = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n", - helpVPTitleStyle.Render("Review Comments"), - m.prRevCmtVP.View())) + helpVPTitleStyle.Render(m.prTLItemDetailTitle), + m.prTLItemDetailVP.View())) } content = prRevCmtsVP case helpView: