Add dashboard / description page
This commit is contained in:
33
README.md
33
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 |
|
||||
|
||||
|
||||
11
config.go
11
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
50
github.go
50
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
5
main.go
5
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,
|
||||
|
||||
245
tui.go
245
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"
|
||||
}
|
||||
|
||||
138
tui_test.go
138
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)
|
||||
}
|
||||
|
||||
|
||||
9
types.go
9
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
|
||||
|
||||
Reference in New Issue
Block a user