diff --git a/README.md b/README.md index 5613045..0b593ff 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # gh-threads A read-only terminal UI for people receiving GitHub pull-request reviews. It -shows open PRs, review threads with highlighted diff hunks and comment authors, -reviewer/assignee state, and the latest commit's check rollup. Resolved threads -start folded. GitHub suggestion blocks are shown as syntax-highlighted -remove/add previews. Comments render GitHub Flavored Markdown, including quoted -replies, inline and fenced code, lists and tasks, links, tables, emphasis, -strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the -background. +shows open PRs and a scrollable PR dashboard with the description, branches, +review state, checks, people, labels, milestone, activity, change statistics, +and thread totals. The thread viewer includes highlighted diff hunks and comment +authors. Resolved threads start folded. GitHub suggestion blocks are shown as +syntax-highlighted remove/add previews. Comments and PR descriptions render +GitHub Flavored Markdown, including quoted replies, inline and fenced code, +lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub +alerts. The current PR is refreshed in the background. ## Install and run @@ -76,6 +77,7 @@ endpoint = "https://api.github.com/graphql" [display] fold_resolved = true thread_list_width_percent = 33 # 20-60 +dashboard_mode = "hotkey" # "hotkey" or "intermediate" [paths] scroll = false @@ -91,25 +93,32 @@ within_status = "file" # "file" or "timestamp" (oldest first) Command-line flags override the configuration. `GH_REPO` overrides the configured repository when `--repo` is not provided. The corresponding flags include `--config`, `--theme`, `--poll`, `--fold-resolved`, -`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean -settings can be disabled explicitly, for example `--path-scroll=false`. +`--thread-list-width`, `--dashboard-mode`, `--path-scroll`, and +`--path-scroll-interval`. Boolean settings can be disabled explicitly, for +example `--path-scroll=false`. + +With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its +review threads and `d` opens the dashboard only when requested. Set +`dashboard_mode = "intermediate"` to follow picker → dashboard → review +threads instead. ## Keys | Key | Action | | --- | --- | | `h` / `l` | Focus the thread list / thread detail | -| `j` / `k` | Move between threads or scroll the focused detail | +| `j` / `k` | Move between items or scroll the dashboard/focused detail | | `?` | Show contextual keybinding help | +| `d` | Open the current pull request dashboard | | `/` | Fuzzy-search thread file paths | | `↑` / `↓` | Choose a fuzzy-search match | | `g` / `G` | First / last item | -| `enter` / `l` | Open a PR | +| `enter` / `l` | Open the selected PR dashboard or its review threads | | `enter` | Toggle the selected review thread | | `za` | Toggle the selected thread | | `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists | | `tab` | Hide or reveal the thread list | -| `b` / `esc` | Return to the PR picker | +| `b` / `esc` | Return to the previous screen | | `r` | Refresh now | | `q` | Quit | diff --git a/config.go b/config.go index 9740de7..3624ccb 100644 --- a/config.go +++ b/config.go @@ -37,8 +37,9 @@ type Config struct { } type DisplayConfig struct { - FoldResolved bool `toml:"fold_resolved"` - ThreadListWidthPercent int `toml:"thread_list_width_percent"` + FoldResolved bool `toml:"fold_resolved"` + ThreadListWidthPercent int `toml:"thread_list_width_percent"` + DashboardMode string `toml:"dashboard_mode"` } type PathConfig struct { @@ -60,6 +61,7 @@ func defaultConfig() Config { Display: DisplayConfig{ FoldResolved: true, ThreadListWidthPercent: 33, + DashboardMode: "hotkey", }, Paths: PathConfig{ Scroll: false, @@ -139,6 +141,11 @@ func validateConfig(config Config) error { if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") } + switch config.Display.DashboardMode { + case "intermediate", "hotkey": + default: + return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey") + } if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { return err } diff --git a/config_test.go b/config_test.go index 74a07ce..5256c89 100644 --- a/config_test.go +++ b/config_test.go @@ -36,6 +36,7 @@ endpoint = "https://github.example.com/api/graphql" [display] fold_resolved = false thread_list_width_percent = 45 +dashboard_mode = "hotkey" [paths] scroll = true @@ -58,6 +59,7 @@ within_status = "timestamp" if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || + got.Display.DashboardMode != "hotkey" || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || got.Threads.WithinStatus != "timestamp" { @@ -145,3 +147,11 @@ func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) { t.Fatal("unknown within-status ordering was accepted") } } + +func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) { + config := defaultConfig() + config.Display.DashboardMode = "sometimes" + if err := validateConfig(config); err == nil { + t.Fatal("unknown dashboard mode was accepted") + } +} diff --git a/github.go b/github.go index 872f0b1..3e3960f 100644 --- a/github.go +++ b/github.go @@ -182,10 +182,15 @@ const detailsQuery = ` query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - id number title url body isDraft updatedAt mergeable reviewDecision + id number title url body isDraft createdAt updatedAt + mergeable mergeStateStatus reviewDecision baseRefName headRefName author { login } assignees(first: 20) { nodes { login } } + labels(first: 20) { nodes { name } } + milestone { title } + additions deletions changedFiles + comments(first: 1) { totalCount } reviewRequests(first: 50) { nodes { requestedReviewer { @@ -196,6 +201,7 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { } latestReviews(first: 50) { nodes { state author { login } } } commits(last: 1) { + totalCount nodes { commit { statusCheckRollup { state } } } } reviewThreads(first: 100) { @@ -227,14 +233,26 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n var data struct { Repository *struct { PullRequest *struct { - ID, Title, URL, Body, Mergeable, ReviewDecision, BaseRefName, HeadRefName string - Number int - IsDraft bool - UpdatedAt time.Time - Author *actor - Assignees struct { + ID, Title, URL, Body, Mergeable, MergeStateStatus string + ReviewDecision, BaseRefName, HeadRefName string + Number, Additions, Deletions, ChangedFiles int + IsDraft bool + CreatedAt, UpdatedAt time.Time + Author *actor + Assignees struct { Nodes []actor `json:"nodes"` } + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } + Milestone *struct { + Title string `json:"title"` + } + Comments struct { + TotalCount int `json:"totalCount"` + } ReviewRequests struct { Nodes []struct { RequestedReviewer actor `json:"requestedReviewer"` @@ -247,7 +265,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n } `json:"nodes"` } Commits struct { - Nodes []struct { + TotalCount int `json:"totalCount"` + Nodes []struct { Commit struct { StatusCheckRollup *struct{ State string } } @@ -295,9 +314,18 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, ReviewCount: len(node.ReviewThreads.Nodes), }, - Body: node.Body, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, - Mergeable: node.Mergeable, ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage, - CheckState: "NONE", ReviewDecision: node.ReviewDecision, + Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, + Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, + Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles, + CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount, + ThreadsTruncated: node.ReviewThreads.PageInfo.HasNextPage, + CheckState: "NONE", ReviewDecision: node.ReviewDecision, + } + for _, label := range node.Labels.Nodes { + details.Labels = append(details.Labels, label.Name) + } + if node.Milestone != nil { + details.Milestone = node.Milestone.Title } for _, assignee := range node.Assignees.Nodes { details.Assignees = append(details.Assignees, assignee.Login) diff --git a/github_test.go b/github_test.go index d2438e3..7961aee 100644 --- a/github_test.go +++ b/github_test.go @@ -89,12 +89,17 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{ "id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false, - "updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED", + "createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z", + "mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED", + "additions":12,"deletions":4,"changedFiles":3, "baseRefName":"main","headRefName":"fix","author":{"login":"zam"}, "assignees":{"nodes":[{"login":"sam"}]}, + "labels":{"nodes":[{"name":"bug"},{"name":"backend"}]}, + "milestone":{"title":"v2"}, + "comments":{"totalCount":5}, "reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]}, "latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]}, - "commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}, + "commits":{"totalCount":7,"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}, "reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{ "id":"t","isResolved":false,"isOutdated":true,"path":"main.go", "line":null,"originalLine":42,"diffSide":"RIGHT", @@ -118,6 +123,12 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" { t.Fatalf("unexpected metadata: %#v", got) } + if got.MergeState != "CLEAN" || got.Additions != 12 || got.Deletions != 4 || + got.ChangedFiles != 3 || got.CommitCount != 7 || got.CommentCount != 5 || + got.Milestone != "v2" || strings.Join(got.Labels, ",") != "bug,backend" || + got.CreatedAt.IsZero() { + t.Fatalf("unexpected dashboard metadata: %#v", got) + } if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 || got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated { t.Fatalf("unexpected thread: %#v", got.Threads) diff --git a/main.go b/main.go index 4438474..525adde 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ func main() { theme = flag.String("theme", defaults.Theme, "color theme: dark or light") foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)") + dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey") pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval") ) @@ -62,6 +63,9 @@ func main() { if visited["thread-list-width"] { config.Display.ThreadListWidthPercent = *listWidth } + if visited["dashboard-mode"] { + config.Display.DashboardMode = *dashboardMode + } if visited["path-scroll"] { config.Paths.Scroll = *pathScroll } @@ -94,6 +98,7 @@ func main() { AppSettings{ FoldResolved: config.Display.FoldResolved, ThreadListWidthPercent: config.Display.ThreadListWidthPercent, + DashboardMode: config.Display.DashboardMode, PathScroll: config.Paths.Scroll, PathScrollInterval: config.Paths.ScrollInterval.Duration, ThreadStatusOrder: config.Threads.StatusOrder, diff --git a/tui.go b/tui.go index b197265..c81c673 100644 --- a/tui.go +++ b/tui.go @@ -17,6 +17,7 @@ type screen int const ( prScreen screen = iota + dashboardScreen threadScreen ) @@ -75,6 +76,8 @@ type App struct { pathScrollStep int threadStatusOrder []string threadWithinStatus string + dashboardMode string + dashboardReturn screen } type AppSettings struct { @@ -84,6 +87,7 @@ type AppSettings struct { PathScrollInterval time.Duration ThreadStatusOrder []string ThreadWithinStatus string + DashboardMode string } func defaultAppSettings() AppSettings { @@ -93,6 +97,7 @@ func defaultAppSettings() AppSettings { PathScrollInterval: 350 * time.Millisecond, ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadWithinStatus: "file", + DashboardMode: "hotkey", } } @@ -115,6 +120,7 @@ func NewAppWithSettings( pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), threadWithinStatus: settings.ThreadWithinStatus, + dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, } } @@ -162,7 +168,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: if !m.loading { m.loading = true - if m.screen == threadScreen && m.details.Number != 0 { + if (m.screen == dashboardScreen || m.screen == threadScreen) && m.details.Number != 0 { return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick()) } return m, tea.Batch(m.loadPRs(), m.nextTick()) @@ -222,7 +228,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.folded[thread.ID] = true } } - m.scroll = min(m.scroll, m.detailMaxScroll()) + if m.screen == dashboardScreen { + m.scroll = min(m.scroll, m.dashboardMaxScroll()) + } else { + m.scroll = min(m.scroll, m.detailMaxScroll()) + } m.err = nil m.lastRefresh = time.Now() } @@ -316,18 +326,22 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.loading = true - if m.screen == threadScreen { + if m.screen == dashboardScreen || m.screen == threadScreen { return m, m.loadDetails(m.details.PullRequest) } return m, m.loadPRs() case "j", "down": - if m.screen == threadScreen && m.focus == threadDetailPane { + if m.screen == dashboardScreen { + m.scrollDashboard(1) + } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(1) } else { m.move(1) } case "k", "up": - if m.screen == threadScreen && m.focus == threadDetailPane { + if m.screen == dashboardScreen { + m.scrollDashboard(-1) + } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(-1) } else { m.move(-1) @@ -350,14 +364,21 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page(1) case "ctrl+u", "pgup": m.page(-1) + case "d": + if m.screen == prScreen && len(m.prs) > 0 { + return m, m.openSelectedPR(dashboardScreen) + } + if m.screen == threadScreen { + m.dashboardReturn, m.screen, m.scroll = threadScreen, dashboardScreen, 0 + return m, nil + } case "l": if m.screen == prScreen && len(m.prs) > 0 { - m.screen = threadScreen - m.details = PRDetails{PullRequest: m.prs[m.prIndex]} - m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil - m.searching, m.searchQuery = false, "" - m.pathScrollStep = 0 - return m, m.loadDetails(m.details.PullRequest) + return m, m.openSelectedPR(m.defaultPRTargetScreen()) + } + if m.screen == dashboardScreen { + m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false + return m, nil } if m.screen == threadScreen { m.focus = threadDetailPane @@ -369,12 +390,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "enter": if m.screen == prScreen && len(m.prs) > 0 { - m.screen = threadScreen - m.details = PRDetails{PullRequest: m.prs[m.prIndex]} - m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil - m.searching, m.searchQuery = false, "" - m.pathScrollStep = 0 - return m, m.loadDetails(m.details.PullRequest) + return m, m.openSelectedPR(m.defaultPRTargetScreen()) + } + if m.screen == dashboardScreen { + m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false + return m, nil } if m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] @@ -383,14 +403,42 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "b", "esc": if m.screen == threadScreen { + if m.dashboardMode == "intermediate" { + m.dashboardReturn, m.screen, m.scroll, m.err = prScreen, dashboardScreen, 0, nil + return m, nil + } m.screen, m.err, m.loading = prScreen, nil, true - m.searching, m.searchQuery = false, "" return m, m.loadPRs() } + if m.screen == dashboardScreen { + m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil + if m.screen == prScreen { + m.loading = true + m.searching, m.searchQuery = false, "" + return m, m.loadPRs() + } + return m, nil + } } return m, nil } +func (m App) defaultPRTargetScreen() screen { + if m.dashboardMode == "hotkey" { + return threadScreen + } + return dashboardScreen +} + +func (m *App) openSelectedPR(target screen) tea.Cmd { + m.screen, m.dashboardReturn = target, prScreen + m.details = PRDetails{PullRequest: m.prs[m.prIndex]} + m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil + m.searching, m.searchQuery = false, "" + m.pathScrollStep = 0 + return m.loadDetails(m.details.PullRequest) +} + func (m *App) move(delta int) { if m.screen == prScreen { m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) @@ -532,6 +580,8 @@ func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) { func (m *App) toStart() { if m.screen == prScreen { m.prIndex = 0 + } else if m.screen == dashboardScreen { + m.scroll = 0 } else if m.focus == threadDetailPane { m.scroll = 0 } else { @@ -541,6 +591,8 @@ func (m *App) toStart() { func (m *App) toEnd() { if m.screen == prScreen { m.prIndex = max(0, len(m.prs)-1) + } else if m.screen == dashboardScreen { + m.scroll = m.dashboardMaxScroll() } else if m.focus == threadDetailPane { m.scroll = m.detailMaxScroll() } else { @@ -548,6 +600,10 @@ func (m *App) toEnd() { } } func (m *App) page(direction int) { + if m.screen == dashboardScreen { + m.scrollDashboard(direction * max(3, m.dashboardViewportHeight()/2)) + return + } if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(direction * max(3, m.detailViewportHeight()/2)) return @@ -559,6 +615,18 @@ func (m *App) scrollDetail(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) } +func (m *App) scrollDashboard(delta int) { + m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll()) +} + +func (m App) dashboardViewportHeight() int { + return max(1, m.height-2) +} + +func (m App) dashboardMaxScroll() int { + return max(0, len(m.dashboardLines())-m.dashboardViewportHeight()) +} + func (m App) detailPaneSize() (int, int) { topLines := 3 if m.details.ThreadsTruncated { @@ -600,6 +668,9 @@ func (m App) View() string { if m.screen == prScreen { return m.viewPRs() } + if m.screen == dashboardScreen { + return m.viewDashboard() + } return m.viewThreads() } @@ -610,17 +681,43 @@ type helpBinding struct { func (m App) helpBindings() []helpBinding { if m.screen == prScreen { + openAction := "Open pull request dashboard" + if m.dashboardMode == "hotkey" { + openAction = "Open review threads" + } return []helpBinding{ {"j / ↓", "Next pull request"}, {"k / ↑", "Previous pull request"}, {"g / G", "First / last pull request"}, {"ctrl-d / ctrl-u", "Page down / up"}, - {"enter / l", "Open pull request"}, + {"enter / l", openAction}, + {"d", "Open pull request dashboard"}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, } } + if m.screen == dashboardScreen { + backAction := "Return to pull requests" + if m.dashboardReturn == threadScreen { + backAction = "Return to review threads" + } + return []helpBinding{ + {"j / ↓", "Scroll description down"}, + {"k / ↑", "Scroll description up"}, + {"g / G", "Top / bottom"}, + {"ctrl-d / ctrl-u", "Page down / up"}, + {"enter / l", "Open review threads"}, + {"b / esc", backAction}, + {"r", "Refresh now"}, + {"?", "Close this help"}, + {"q / ctrl-c", "Quit"}, + } + } + backAction := "Return to pull requests" + if m.dashboardMode == "intermediate" { + backAction = "Return to PR dashboard" + } return []helpBinding{ {"h / l", "Focus thread list / detail"}, {"j / k", "Move or scroll focused pane"}, @@ -629,8 +726,9 @@ func (m App) helpBindings() []helpBinding { {"ctrl-d / ctrl-u", "Page down / up"}, {"tab", "Hide / reveal thread list"}, {"/", "Fuzzy-search file paths"}, + {"d", "Open pull request dashboard"}, {"enter / za", "Fold / expand thread"}, - {"b / esc", "Return to pull requests"}, + {"b / esc", backAction}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, @@ -654,7 +752,9 @@ func (m App) viewHelp() string { keyWidth := min(17, max(8, contentWidth/3)) title := "Pull request picker keys" - if m.screen == threadScreen { + if m.screen == dashboardScreen { + title = "Pull request dashboard keys" + } else if m.screen == threadScreen { title = "Review thread keys" } lines := []string{titleStyle.Render(title)} @@ -739,7 +839,11 @@ func (m App) viewPRs() string { } lines = append(lines, line) } - return m.frame(lines, "? keys • j/k move • enter open • q quit") + footer := "? keys • j/k move • enter dashboard • q quit" + if m.dashboardMode == "hotkey" { + footer = "? keys • j/k move • enter threads • d dashboard • q quit" + } + return m.frame(lines, footer) } type prListRow struct { @@ -769,6 +873,101 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) { return rows, selectedRow } +func (m App) viewDashboard() string { + lines := m.dashboardLines() + viewportHeight := m.dashboardViewportHeight() + maxScroll := max(0, len(lines)-viewportHeight) + scroll := min(m.scroll, maxScroll) + visible := lines[scroll:min(len(lines), scroll+viewportHeight)] + return m.frame(visible, "? keys • j/k scroll • enter threads • b back • q quit") +} + +func (m App) dashboardLines() []string { + pr := m.details + width := max(10, m.width-2) + draft := "" + if pr.IsDraft { + draft = " " + warnStyle.Render("DRAFT") + } + lines := []string{ + titleStyle.Render(fmt.Sprintf("%s #%d", pr.RepoWithOwner, pr.Number)) + draft, + titleStyle.Render(truncate(pr.Title, width)), + } + if m.loading && pr.BaseRef == "" { + return append(lines, "", "Loading pull request details…") + } + + open, outdated, resolved := threadStatusCounts(pr.Threads) + created := "unknown" + if !pr.CreatedAt.IsZero() { + created = pr.CreatedAt.Local().Format("2006-01-02 15:04") + } + updated := "unknown" + if !pr.UpdatedAt.IsZero() { + updated = pr.UpdatedAt.Local().Format("2006-01-02 15:04") + } + milestone := firstNonEmpty(pr.Milestone, "none") + labels := "none" + if len(pr.Labels) > 0 { + labels = strings.Join(pr.Labels, ", ") + } + lines = append(lines, + "", + dashboardMetadata("author", authorStyle(pr.Author).Render("@"+pr.Author)), + dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef), + dashboardMetadata("review", reviewAndMergeState(pr)), + dashboardMetadata("checks", coloredState(pr.CheckState)), + dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), + dashboardMetadata("assignees", handlesText(pr.Assignees)), + dashboardMetadata("reviewers", reviewersText(pr.Reviewers)), + dashboardMetadata("labels", labels), + dashboardMetadata("milestone", milestone), + dashboardMetadata("activity", fmt.Sprintf( + "%d commits • %d conversation comments", pr.CommitCount, pr.CommentCount, + )), + dashboardMetadata("changes", fmt.Sprintf( + "%s %s • %d files", + okStyle.Render(fmt.Sprintf("+%d", pr.Additions)), + badStyle.Render(fmt.Sprintf("-%d", pr.Deletions)), + pr.ChangedFiles, + )), + dashboardMetadata("threads", fmt.Sprintf( + "%d open • %d outdated • %d resolved", open, outdated, resolved, + )), + dashboardMetadata("created", created), + dashboardMetadata("updated", updated), + dashboardMetadata("url", pr.URL), + ) + if pr.ThreadsTruncated { + lines = append(lines, warnStyle.Render("Thread totals only include the first 100 review threads.")) + } + lines = append(lines, "", titleStyle.Render("Description"), "") + if strings.TrimSpace(pr.Body) == "" { + lines = append(lines, dimStyle.Render("No description provided.")) + } else { + lines = append(lines, renderCommentMarkdown(pr.Body, width)...) + } + return lines +} + +func dashboardMetadata(label, value string) string { + return titleStyle.Render(pad(label+":", 13)) + " " + value +} + +func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) { + for _, thread := range threads { + switch threadStatus(thread) { + case "resolved": + resolved++ + case "outdated": + outdated++ + default: + open++ + } + } + return open, outdated, resolved +} + func (m App) viewThreads() string { pr := m.details header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) @@ -796,7 +995,7 @@ func (m App) viewThreads() string { right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } - help := "? keys • h/l focus • j/k move/scroll • b back • q quit" + help := "? keys • h/l focus • j/k move/scroll • d dashboard • b back • q quit" if m.searching { help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel" } diff --git a/tui_test.go b/tui_test.go index 34e5d53..96ce1e0 100644 --- a/tui_test.go +++ b/tui_test.go @@ -166,6 +166,142 @@ func TestPaneFocusAndNavigation(t *testing.T) { } } +func TestOpeningPullRequestShowsDashboardBeforeThreads(t *testing.T) { + settings := defaultAppSettings() + settings.DashboardMode = "intermediate" + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.prs = []PullRequest{{ + ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, + }} + updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(App) + if m.screen != dashboardScreen || m.details.Number != 9 || command == nil { + t.Fatalf("opening PR produced screen=%d number=%d command=%v", m.screen, m.details.Number, command) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(App) + if m.screen != threadScreen { + t.Fatal("enter did not open review threads from dashboard") + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + m = updated.(App) + if m.screen != dashboardScreen { + t.Fatal("b did not return from threads to dashboard") + } +} + +func TestDashboardHotkeyModeIsDefault(t *testing.T) { + if got := defaultAppSettings().DashboardMode; got != "hotkey" { + t.Fatalf("default dashboard mode = %q, want hotkey", got) + } + if got := defaultConfig().Display.DashboardMode; got != "hotkey" { + t.Fatalf("default config dashboard mode = %q, want hotkey", got) + } +} + +func TestHotkeyDashboardModeOpensThreadsDirectly(t *testing.T) { + settings := defaultAppSettings() + settings.DashboardMode = "hotkey" + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.prs = []PullRequest{{ + ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, + }} + + updated, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(App) + if m.screen != threadScreen || command == nil { + t.Fatalf("hotkey mode opened screen=%d command=%v", m.screen, command) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(App) + if m.screen != dashboardScreen || m.dashboardReturn != threadScreen { + t.Fatal("d did not open a dashboard that returns to threads") + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}) + m = updated.(App) + if m.screen != threadScreen { + t.Fatal("dashboard did not return to the invoking thread screen") + } +} + +func TestDashboardHotkeyWorksFromPicker(t *testing.T) { + settings := defaultAppSettings() + settings.DashboardMode = "hotkey" + m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings) + m.prs = []PullRequest{{ + ID: "pr", Owner: "owner", Repository: "repo", RepoWithOwner: "owner/repo", Number: 9, + }} + + updated, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(App) + if m.screen != dashboardScreen || m.dashboardReturn != prScreen || command == nil { + t.Fatalf("picker dashboard hotkey produced screen=%d return=%d", m.screen, m.dashboardReturn) + } +} + +func TestDashboardRendersDescriptionAndMetadata(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.screen = dashboardScreen + m.width, m.height = 100, 40 + m.details = PRDetails{ + PullRequest: PullRequest{ + RepoWithOwner: "owner/repo", Number: 9, Title: "Improve dashboard", + Author: "alice", URL: "https://github.com/owner/repo/pull/9", + UpdatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC), + }, + Body: "> Existing behavior\n\nUse `new_behavior` instead.", + CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + BaseRef: "main", + HeadRef: "feature", + ReviewDecision: "REVIEW_REQUIRED", + CheckState: "SUCCESS", + MergeState: "CLEAN", + Assignees: []string{"bob"}, + Reviewers: []Reviewer{{Login: "carol", State: "APPROVED"}}, + Labels: []string{"ui", "review"}, + Milestone: "v2", + Additions: 20, + Deletions: 4, + ChangedFiles: 3, + CommitCount: 2, + CommentCount: 5, + Threads: []ReviewThread{ + {ID: "open"}, + {ID: "old", IsOutdated: true}, + {ID: "done", IsResolved: true, IsOutdated: true}, + }, + } + plain := ansi.Strip(m.View()) + for _, wanted := range []string{ + "Improve dashboard", "@alice", "feature → main", "@bob", "@carol", + "ui, review", "v2", "+20", "-4", "3 files", "2 commits", + "1 open", "1 outdated", "1 resolved", "Description", + "Existing behavior", "new_behavior", + } { + if !strings.Contains(plain, wanted) { + t.Fatalf("dashboard is missing %q:\n%s", wanted, plain) + } + } +} + +func TestDashboardDescriptionScrolls(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.screen = dashboardScreen + m.width, m.height = 60, 10 + m.details = PRDetails{ + PullRequest: PullRequest{RepoWithOwner: "owner/repo", Number: 1, Title: "Long", Author: "alice"}, + BaseRef: "main", + HeadRef: "feature", + Body: strings.Repeat("A long description line.\n\n", 20), + } + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) + m = updated.(App) + if m.scroll == 0 || m.scroll != m.dashboardMaxScroll() { + t.Fatalf("dashboard scroll = %d, max = %d", m.scroll, m.dashboardMaxScroll()) + } +} + func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen @@ -179,7 +315,7 @@ func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { plain := ansi.Strip(m.View()) if !strings.Contains(plain, "Review thread keys") || !strings.Contains(plain, "Fuzzy-search file paths") || - strings.Contains(plain, "Open pull request") { + strings.Contains(plain, "Next pull request") { t.Fatalf("help was not contextual:\n%s", plain) } diff --git a/types.go b/types.go index 5855305..8620320 100644 --- a/types.go +++ b/types.go @@ -20,11 +20,20 @@ type PullRequest struct { type PRDetails struct { PullRequest Body string + CreatedAt time.Time BaseRef string HeadRef string Mergeable string + MergeState string Assignees []string Reviewers []Reviewer + Labels []string + Milestone string + Additions int + Deletions int + ChangedFiles int + CommitCount int + CommentCount int CheckState string ReviewDecision string Threads []ReviewThread