Improve highlighting and add QoL changes

This commit is contained in:
2026-07-27 14:28:32 +02:00
parent 6bfe248388
commit 547cd123da
11 changed files with 989 additions and 109 deletions

View File

@@ -4,7 +4,10 @@ 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, shows open PRs, review threads with highlighted diff hunks and comment authors,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads reviewer/assignee state, and the latest commit's check rollup. Resolved threads
start folded. GitHub suggestion blocks are shown as syntax-highlighted start folded. GitHub suggestion blocks are shown as syntax-highlighted
remove/add previews. The current PR is refreshed in the background. 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.
## Install and run ## Install and run
@@ -13,13 +16,13 @@ Requires Go 1.24+ and an authenticated GitHub CLI:
```sh ```sh
go install . go install .
gh auth login gh auth login
gh-threads --repo owner/repository gh-threads
``` ```
To run directly from a source checkout instead: To run directly from a source checkout instead:
```sh ```sh
go run . --repo owner/repository go run .
``` ```
Use `go run .`, not `go run main.go`: the latter compiles only `main.go` and Use `go run .`, not `go run main.go`: the latter compiles only `main.go` and
@@ -29,8 +32,16 @@ For automation, `GH_TOKEN` or `GITHUB_TOKEN` can still be provided and takes
precedence over the GitHub CLI credential. Enterprise token environment precedence over the GitHub CLI credential. Enterprise token environment
variables are also supported. variables are also supported.
By default the PR picker only includes open PRs authored by the authenticated By default the PR picker searches all repositories for open PRs assigned to the
user. Pass `--all` to include every open PR: authenticated user and groups the results by repository. Use `--repo` to limit
the picker to one repository:
```sh
gh-threads --repo owner/repository
```
With a repository selected, pass `--all` to include every open PR in that
repository:
```sh ```sh
gh-threads --repo owner/repository --all --poll 15s gh-threads --repo owner/repository --all --poll 15s
@@ -50,6 +61,9 @@ gh-threads --repo owner/repository \
| --- | --- | | --- | --- |
| `h` / `l` | Focus the thread list / thread detail | | `h` / `l` | Focus the thread list / thread detail |
| `j` / `k` | Move between threads or scroll the focused detail | | `j` / `k` | Move between threads or scroll the focused detail |
| `?` | Show contextual keybinding help |
| `/` | Fuzzy-search thread file paths |
| `↑` / `↓` | Choose a fuzzy-search match |
| `g` / `G` | First / last item | | `g` / `G` | First / last item |
| `enter` / `l` | Open a PR | | `enter` / `l` | Open a PR |
| `enter` | Toggle the selected review thread | | `enter` | Toggle the selected review thread |
@@ -64,5 +78,6 @@ gh-threads --repo owner/repository \
The application is intentionally read-only. GitHub's GraphQL API currently The application is intentionally read-only. GitHub's GraphQL API currently
limits this client to the first 100 review threads and first 100 comments per limits this client to the first 100 review threads and first 100 comments per
thread; the UI warns when the thread list is truncated. Markdown in comments is thread; the UI warns when the thread list is truncated. GitHub features which
displayed as readable wrapped text rather than fully rendered Markdown. depend on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal.

View File

@@ -90,13 +90,18 @@ func (c *GitHubClient) query(ctx context.Context, query string, variables map[st
} }
const listPRsQuery = ` const listPRsQuery = `
query PullRequests($owner: String!, $name: String!, $limit: Int!) { query PullRequests($query: String!, $limit: Int!) {
viewer { login } viewer { login }
repository(owner: $owner, name: $name) { search(query: $query, type: ISSUE, first: $limit) {
pullRequests(first: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes { nodes {
... on PullRequest {
id number title url isDraft updatedAt id number title url isDraft updatedAt
author { login } author { login }
repository {
name
nameWithOwner
owner { login }
}
reviewThreads(first: 1) { totalCount } reviewThreads(first: 1) { totalCount }
} }
} }
@@ -104,12 +109,14 @@ query PullRequests($owner: String!, $name: String!, $limit: Int!) {
}` }`
func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) { func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string, limit int, showAll bool) ([]PullRequest, error) {
if showAll && owner == "" {
return nil, errors.New("--all requires --repo to avoid an unbounded global search")
}
var data struct { var data struct {
Viewer struct { Viewer struct {
Login string `json:"login"` Login string `json:"login"`
} `json:"viewer"` } `json:"viewer"`
Repository *struct { Search struct {
PullRequests struct {
Nodes []struct { Nodes []struct {
ID string `json:"id"` ID string `json:"id"`
Number int `json:"number"` Number int `json:"number"`
@@ -120,36 +127,54 @@ func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string,
Author *struct { Author *struct {
Login string `json:"login"` Login string `json:"login"`
} `json:"author"` } `json:"author"`
Repository struct {
Name string `json:"name"`
NameWithOwner string `json:"nameWithOwner"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
} `json:"repository"`
ReviewThreads struct { ReviewThreads struct {
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
} `json:"reviewThreads"` } `json:"reviewThreads"`
} `json:"nodes"` } `json:"nodes"`
} `json:"pullRequests"` } `json:"search"`
} `json:"repository"`
} }
if err := c.query(ctx, listPRsQuery, map[string]any{"owner": owner, "name": name, "limit": limit}, &data); err != nil { search := "is:pr is:open sort:updated-desc"
if owner != "" {
search += " repo:" + owner + "/" + name
}
if !showAll {
search += " assignee:@me"
}
if err := c.query(ctx, listPRsQuery, map[string]any{"query": search, "limit": limit}, &data); err != nil {
return nil, err return nil, err
} }
if data.Repository == nil {
return nil, fmt.Errorf("repository %s/%s was not found or is not accessible", owner, name)
}
prs := make([]PullRequest, 0, len(data.Repository.PullRequests.Nodes)) prs := make([]PullRequest, 0, len(data.Search.Nodes))
for _, node := range data.Repository.PullRequests.Nodes { for _, node := range data.Search.Nodes {
if node.Repository.NameWithOwner == "" {
continue
}
author := "[ghost]" author := "[ghost]"
if node.Author != nil { if node.Author != nil {
author = node.Author.Login author = node.Author.Login
} }
mine := author == data.Viewer.Login
if !showAll && !mine {
continue
}
prs = append(prs, PullRequest{ prs = append(prs, PullRequest{
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL, ID: node.ID, Owner: node.Repository.Owner.Login, Repository: node.Repository.Name,
RepoWithOwner: node.Repository.NameWithOwner,
Number: node.Number, Title: node.Title, URL: node.URL,
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: mine, ReviewCount: node.ReviewThreads.TotalCount, ViewerAuthored: author == data.Viewer.Login,
}) })
} }
sort.SliceStable(prs, func(i, j int) bool {
left, right := strings.ToLower(prs[i].RepoWithOwner), strings.ToLower(prs[j].RepoWithOwner)
if left != right {
return left < right
}
return prs[i].UpdatedAt.After(prs[j].UpdatedAt)
})
return prs, nil return prs, nil
} }
@@ -265,7 +290,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
} }
details := PRDetails{ details := PRDetails{
PullRequest: PullRequest{ PullRequest: PullRequest{
ID: node.ID, Number: node.Number, Title: node.Title, URL: node.URL, ID: node.ID, Owner: owner, Repository: name, RepoWithOwner: owner + "/" + name,
Number: node.Number, Title: node.Title, URL: node.URL,
Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt, Author: author, IsDraft: node.IsDraft, UpdatedAt: node.UpdatedAt,
ReviewCount: len(node.ReviewThreads.Nodes), ReviewCount: len(node.ReviewThreads.Nodes),
}, },

View File

@@ -9,17 +9,28 @@ import (
"testing" "testing"
) )
func TestListPullRequestsFiltersToViewer(t *testing.T) { func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" { if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Fatalf("authorization = %q", got) t.Fatalf("authorization = %q", got)
} }
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if got := request.Variables["query"]; got != "is:pr is:open sort:updated-desc repo:o/r assignee:@me" {
t.Fatalf("search query = %q", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{
"viewer": map[string]any{"login": "zam"}, "viewer": map[string]any{"login": "zam"},
"repository": map[string]any{"pullRequests": map[string]any{"nodes": []any{ "search": map[string]any{"nodes": []any{
map[string]any{"id": "1", "number": 1, "title": "mine", "url": "u", "isDraft": false, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "zam"}, "reviewThreads": map[string]any{"totalCount": 2}}, map[string]any{
map[string]any{"id": "2", "number": 2, "title": "theirs", "url": "u", "isDraft": false, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "other"}, "reviewThreads": map[string]any{"totalCount": 1}}, "id": "1", "number": 1, "title": "assigned", "url": "u", "isDraft": false,
}}}, "updatedAt": "2026-01-01T00:00:00Z", "author": map[string]any{"login": "other"},
"repository": map[string]any{"name": "r", "nameWithOwner": "o/r", "owner": map[string]any{"login": "o"}},
"reviewThreads": map[string]any{"totalCount": 2},
},
}},
}}) }})
})) }))
defer server.Close() defer server.Close()
@@ -29,11 +40,39 @@ func TestListPullRequestsFiltersToViewer(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(prs) != 1 || prs[0].Number != 1 || prs[0].ReviewCount != 2 { if len(prs) != 1 || prs[0].Number != 1 || prs[0].ReviewCount != 2 ||
prs[0].Owner != "o" || prs[0].Repository != "r" || prs[0].RepoWithOwner != "o/r" {
t.Fatalf("unexpected PRs: %#v", prs) t.Fatalf("unexpected PRs: %#v", prs)
} }
} }
func TestListPullRequestsSearchesAllRepositoriesByDefault(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
query, _ := request.Variables["query"].(string)
if !strings.Contains(query, "assignee:@me") || strings.Contains(query, "repo:") {
t.Fatalf("global search query = %q", query)
}
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"zam"},"search":{"nodes":[]}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
if _, err := client.ListPullRequests(context.Background(), "", "", 50, false); err != nil {
t.Fatal(err)
}
}
func TestListPullRequestsRejectsGlobalShowAll(t *testing.T) {
client := NewGitHubClient("unused", "secret")
if _, err := client.ListPullRequests(context.Background(), "", "", 50, true); err == nil {
t.Fatal("global --all search was accepted")
}
}
func TestGraphQLErrorsAreReturned(t *testing.T) { func TestGraphQLErrorsAreReturned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`)) _, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))

24
go.mod
View File

@@ -5,26 +5,36 @@ go 1.24.0
require ( require (
github.com/alecthomas/chroma/v2 v2.20.0 github.com/alecthomas/chroma/v2 v2.20.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.10.1 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.10.2
) )
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect github.com/yuin/goldmark v1.7.13 // indirect
golang.org/x/text v0.3.8 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
) )

54
go.sum
View File

@@ -6,48 +6,74 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=

15
main.go
View File

@@ -12,17 +12,24 @@ import (
func main() { func main() {
var ( var (
repo = flag.String("repo", os.Getenv("GH_REPO"), "GitHub repository as owner/name (or GH_REPO)") repo = flag.String("repo", os.Getenv("GH_REPO"), "optional GitHub repository filter as owner/name (or GH_REPO)")
poll = flag.Duration("poll", 10*time.Second, "refresh interval") poll = flag.Duration("poll", 10*time.Second, "refresh interval")
showAll = flag.Bool("all", false, "show all open PRs, not only PRs authored by you") showAll = flag.Bool("all", false, "show all open PRs in --repo, not only PRs assigned to you")
limit = flag.Int("limit", 50, "maximum open PRs to load (1-100)") limit = flag.Int("limit", 50, "maximum open PRs to load (1-100)")
endpoint = flag.String("endpoint", "https://api.github.com/graphql", "GitHub GraphQL endpoint") endpoint = flag.String("endpoint", "https://api.github.com/graphql", "GitHub GraphQL endpoint")
) )
flag.Parse() flag.Parse()
var owner, name string
if *repo != "" {
parts := strings.Split(*repo, "/") parts := strings.Split(*repo, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
exitf("--repo owner/name (or GH_REPO) is required") exitf("--repo must be owner/name")
}
owner, name = parts[0], parts[1]
}
if *showAll && owner == "" {
exitf("--all requires --repo")
} }
if *limit < 1 || *limit > 100 { if *limit < 1 || *limit > 100 {
exitf("--limit must be between 1 and 100") exitf("--limit must be between 1 and 100")
@@ -36,7 +43,7 @@ func main() {
} }
client := NewGitHubClient(*endpoint, token) client := NewGitHubClient(*endpoint, token)
app := NewApp(client, parts[0], parts[1], *showAll, *limit, *poll) app := NewApp(client, owner, name, *showAll, *limit, *poll)
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil { if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil {
exitf("run TUI: %v", err) exitf("run TUI: %v", err)
} }

145
markdown.go Normal file
View File

@@ -0,0 +1,145 @@
package main
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
)
var commentMarkdownRenderers sync.Map
var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
func renderCommentMarkdown(markdown string, width int) []string {
if strings.TrimSpace(markdown) == "" {
return nil
}
width = max(10, width)
markdown = normalizeGitHubAlerts(markdown)
var (
result []string
block []string
blockQuote bool
haveBlock bool
)
flush := func() {
if len(block) == 0 {
return
}
blockWidth := width
if blockQuote {
blockWidth = max(8, width-2)
}
lines := renderMarkdownFragment(strings.Join(block, "\n"), blockWidth)
if haveBlock && len(lines) > 0 {
result = append(result, "")
}
if blockQuote {
rail := quoteRailStyle.Render("│ ")
for i := range lines {
lines[i] = rail + lines[i]
}
}
result = append(result, lines...)
haveBlock = haveBlock || len(lines) > 0
block = nil
}
for _, line := range strings.Split(markdown, "\n") {
content, quoted := stripQuoteMarker(line)
if len(block) > 0 && quoted != blockQuote {
flush()
}
blockQuote = quoted
block = append(block, content)
}
flush()
return trimMarkdownLines(result)
}
func renderMarkdownFragment(markdown string, width int) []string {
if strings.TrimSpace(markdown) == "" {
return nil
}
renderer, err := commentMarkdownRenderer(width)
if err != nil {
return fallbackCommentLines(markdown, width)
}
rendered, err := renderer.Render(markdown)
if err != nil {
return fallbackCommentLines(markdown, width)
}
return trimMarkdownLines(strings.Split(strings.Trim(rendered, "\n"), "\n"))
}
func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
if cached, ok := commentMarkdownRenderers.Load(width); ok {
return cached.(*glamour.TermRenderer), nil
}
style := styles.DarkStyleConfig
zero := uint(0)
style.Document.Margin = &zero
style.Code.Prefix = ""
style.Code.Suffix = ""
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(width),
glamour.WithTableWrap(true),
glamour.WithPreservedNewLines(),
glamour.WithEmoji(),
)
if err != nil {
return nil, err
}
actual, _ := commentMarkdownRenderers.LoadOrStore(width, renderer)
return actual.(*glamour.TermRenderer), nil
}
func stripQuoteMarker(line string) (string, bool) {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, ">") {
return line, false
}
content := strings.TrimPrefix(trimmed, ">")
content = strings.TrimPrefix(content, " ")
return content, true
}
func normalizeGitHubAlerts(markdown string) string {
alerts := map[string]string{
"[!NOTE]": " **Note**",
"[!TIP]": "◆ **Tip**",
"[!IMPORTANT]": "❗ **Important**",
"[!WARNING]": "⚠ **Warning**",
"[!CAUTION]": "⛔ **Caution**",
}
lines := strings.Split(markdown, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, ">") {
continue
}
label := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
if replacement, ok := alerts[strings.ToUpper(label)]; ok {
prefix := line[:strings.Index(line, ">")+1]
lines[i] = prefix + " " + replacement
}
}
return strings.Join(lines, "\n")
}
func fallbackCommentLines(markdown string, width int) []string {
return strings.Split(wrap(markdown, max(10, width)), "\n")
}
func trimMarkdownLines(lines []string) []string {
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}

88
markdown_test.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestCommentMarkdownDistinguishesQuoteAndReply(t *testing.T) {
lines := renderCommentMarkdown("> This is the quoted review.\n\nThis is my reply.", 60)
rendered := strings.Join(lines, "\n")
plain := ansi.Strip(rendered)
if !strings.Contains(plain, "This is the quoted review.") ||
!strings.Contains(plain, "This is my reply.") {
t.Fatalf("comment content was lost:\n%q", rendered)
}
if !strings.Contains(plain, "│") && !strings.Contains(plain, "┃") &&
!strings.Contains(plain, ">") {
t.Fatalf("quote has no visible marker:\n%q", rendered)
}
}
func TestCommentMarkdownStylesInlineCode(t *testing.T) {
rendered := strings.Join(renderCommentMarkdown(
"Use `list_comparison` for this value.", 60,
), "\n")
plain := ansi.Strip(rendered)
if !strings.Contains(plain, "Use list_comparison for this value.") {
t.Fatalf("inline code was lost: %q", rendered)
}
if strings.Contains(plain, "Use list_comparison for") {
t.Fatalf("inline code added surrounding spaces: %q", plain)
}
if !strings.Contains(rendered, "48;5;236m") {
t.Fatalf("inline code has no distinct background: %q", rendered)
}
}
func TestWrappedQuoteKeepsRailOnEveryLine(t *testing.T) {
const width = 28
lines := renderCommentMarkdown(
"> This quoted reply is deliberately long enough to wrap across several terminal lines.",
width,
)
if len(lines) < 2 {
t.Fatalf("quote did not wrap: %#v", lines)
}
for i, line := range lines {
plain := ansi.Strip(line)
if strings.TrimSpace(plain) != "" && !strings.HasPrefix(plain, "│ ") {
t.Fatalf("wrapped quote line %d lost its rail: %q", i, plain)
}
if ansi.StringWidth(line) > width {
t.Fatalf("wrapped quote line %d is too wide: %q", i, plain)
}
}
}
func TestCommentMarkdownRendersGFMStructures(t *testing.T) {
body := "## Result\n\n- [x] done\n- [ ] pending\n\n~~old~~ **new** :+1:\n\n| Name | State |\n| --- | --- |\n| check | good |"
plain := ansi.Strip(strings.Join(renderCommentMarkdown(body, 70), "\n"))
for _, wanted := range []string{"Result", "done", "pending", "old", "new", "👍", "Name", "State", "check", "good"} {
if !strings.Contains(plain, wanted) {
t.Fatalf("rendered Markdown lost %q:\n%s", wanted, plain)
}
}
}
func TestCommentMarkdownRendersGitHubAlert(t *testing.T) {
plain := ansi.Strip(strings.Join(renderCommentMarkdown(
"> [!WARNING]\n> This can delete data.", 60,
), "\n"))
if !strings.Contains(plain, "Warning") || !strings.Contains(plain, "This can delete data.") {
t.Fatalf("GitHub alert was not rendered structurally:\n%s", plain)
}
}
func TestCommentMarkdownStaysWithinRequestedWidth(t *testing.T) {
const width = 34
body := "> A long quoted reply containing `inline_code` that needs wrapping.\n\n" +
"- A similarly long list item that also needs wrapping."
for i, line := range renderCommentMarkdown(body, width) {
if got := ansi.StringWidth(line); got > width {
t.Fatalf("line %d width = %d, want at most %d: %q", i, got, width, line)
}
}
}

400
tui.go
View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"sort"
"strings" "strings"
"time" "time"
@@ -32,6 +33,8 @@ type prsLoadedMsg struct {
err error err error
} }
type detailsLoadedMsg struct { type detailsLoadedMsg struct {
owner string
repo string
number int number int
details PRDetails details PRDetails
err error err error
@@ -58,6 +61,11 @@ type App struct {
err error err error
lastRefresh time.Time lastRefresh time.Time
pendingZ bool pendingZ bool
searching bool
searchQuery string
searchOrigin int
helpVisible bool
helpScroll int
} }
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App { func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
@@ -84,12 +92,15 @@ func (m App) loadPRs() tea.Cmd {
} }
} }
func (m App) loadDetails(number int) tea.Cmd { func (m App) loadDetails(pr PullRequest) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
details, err := m.service.GetPullRequest(ctx, m.owner, m.repo, number) details, err := m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number)
return detailsLoadedMsg{number: number, details: details, err: err} return detailsLoadedMsg{
owner: pr.Owner, repo: pr.Repository, number: pr.Number,
details: details, err: err,
}
} }
} }
@@ -101,7 +112,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.loading { if !m.loading {
m.loading = true m.loading = true
if m.screen == threadScreen && m.details.Number != 0 { if m.screen == threadScreen && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.Number), m.nextTick()) return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick())
} }
return m, tea.Batch(m.loadPRs(), m.nextTick()) return m, tea.Batch(m.loadPRs(), m.nextTick())
} }
@@ -112,17 +123,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
} }
selected := 0 selected := ""
if len(m.prs) > 0 && m.prIndex < len(m.prs) { if len(m.prs) > 0 && m.prIndex < len(m.prs) {
selected = m.prs[m.prIndex].Number selected = m.prs[m.prIndex].ID
} }
m.prs = msg.prs m.prs = msg.prs
sort.SliceStable(m.prs, func(i, j int) bool {
left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner)
if left != right {
return left < right
}
return m.prs[i].UpdatedAt.After(m.prs[j].UpdatedAt)
})
m.prIndex = indexPR(m.prs, selected) m.prIndex = indexPR(m.prs, selected)
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
case detailsLoadedMsg: case detailsLoadedMsg:
m.loading = false m.loading = false
if msg.number != m.details.Number && m.details.Number != 0 { if m.details.Number != 0 && (msg.number != m.details.Number ||
msg.owner != m.details.Owner || msg.repo != m.details.Repository) {
return m, nil return m, nil
} }
if msg.err != nil { if msg.err != nil {
@@ -153,9 +172,66 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
k := key.String() k := key.String()
if m.helpVisible {
switch k {
case "ctrl+c":
return m, tea.Quit
case "?", "esc", "q":
m.helpVisible, m.helpScroll = false, 0
case "j", "down":
m.helpScroll = min(m.helpScroll+1, m.helpMaxScroll())
case "k", "up":
m.helpScroll = max(0, m.helpScroll-1)
case "g":
m.helpScroll = 0
case "G":
m.helpScroll = m.helpMaxScroll()
case "ctrl+d", "pgdown":
m.helpScroll = min(m.helpScroll+max(3, m.height/2), m.helpMaxScroll())
case "ctrl+u", "pgup":
m.helpScroll = max(0, m.helpScroll-max(3, m.height/2))
}
return m, nil
}
if m.searching {
switch k {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.searching, m.searchQuery = false, ""
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
m.scroll = 0
case "enter":
m.searching, m.searchQuery = false, ""
case "up":
m.moveSearch(-1)
case "down", "tab":
m.moveSearch(1)
case "backspace":
runes := []rune(m.searchQuery)
if len(runes) > 0 {
m.searchQuery = string(runes[:len(runes)-1])
m.selectBestSearchMatch()
}
case "ctrl+u":
m.searchQuery = ""
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
m.scroll = 0
default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
m.searchQuery += string(key.Runes)
m.selectBestSearchMatch()
}
}
return m, nil
}
if k == "ctrl+c" || k == "q" { if k == "ctrl+c" || k == "q" {
return m, tea.Quit return m, tea.Quit
} }
if k == "?" {
m.helpVisible, m.helpScroll = true, 0
return m, nil
}
if m.pendingZ { if m.pendingZ {
m.pendingZ = false m.pendingZ = false
if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 { if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 {
@@ -170,13 +246,18 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
switch k { switch k {
case "/":
if m.screen == threadScreen {
m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex
m.focus, m.listHidden, m.scroll = threadListPane, false, 0
}
case "r": case "r":
if m.loading { if m.loading {
return m, nil return m, nil
} }
m.loading = true m.loading = true
if m.screen == threadScreen { if m.screen == threadScreen {
return m, m.loadDetails(m.details.Number) return m, m.loadDetails(m.details.PullRequest)
} }
return m, m.loadPRs() return m, m.loadPRs()
case "j", "down": case "j", "down":
@@ -214,7 +295,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = threadScreen m.screen = threadScreen
m.details = PRDetails{PullRequest: m.prs[m.prIndex]} 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.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
return m, m.loadDetails(m.details.Number) m.searching, m.searchQuery = false, ""
return m, m.loadDetails(m.details.PullRequest)
} }
if m.screen == threadScreen { if m.screen == threadScreen {
m.focus = threadDetailPane m.focus = threadDetailPane
@@ -229,7 +311,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.screen = threadScreen m.screen = threadScreen
m.details = PRDetails{PullRequest: m.prs[m.prIndex]} 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.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
return m, m.loadDetails(m.details.Number) m.searching, m.searchQuery = false, ""
return m, m.loadDetails(m.details.PullRequest)
} }
if m.screen == threadScreen && len(m.details.Threads) > 0 { if m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex] thread := m.details.Threads[m.threadIndex]
@@ -239,6 +322,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "b", "esc": case "b", "esc":
if m.screen == threadScreen { if m.screen == threadScreen {
m.screen, m.err, m.loading = prScreen, nil, true m.screen, m.err, m.loading = prScreen, nil, true
m.searching, m.searchQuery = false, ""
return m, m.loadPRs() return m, m.loadPRs()
} }
} }
@@ -254,6 +338,59 @@ func (m *App) move(delta int) {
m.scroll = 0 m.scroll = 0
} }
func (m *App) moveSearch(delta int) {
matches := m.matchingThreadIndices()
if len(matches) == 0 {
return
}
position := 0
for i, index := range matches {
if index == m.threadIndex {
position = i
break
}
}
position = clamp(position+delta, 0, len(matches)-1)
m.threadIndex = matches[position]
m.scroll = 0
}
func (m *App) selectBestSearchMatch() {
matches := m.matchingThreadIndices()
if len(matches) > 0 {
m.threadIndex = matches[0]
m.scroll = 0
}
}
func (m App) matchingThreadIndices() []int {
if m.searchQuery == "" {
indices := make([]int, len(m.details.Threads))
for i := range m.details.Threads {
indices[i] = i
}
return indices
}
type match struct {
index int
score int
}
matches := make([]match, 0, len(m.details.Threads))
for i, thread := range m.details.Threads {
if score, ok := fuzzyPathScore(thread.Path, m.searchQuery); ok {
matches = append(matches, match{index: i, score: score})
}
}
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})
indices := make([]int, len(matches))
for i, match := range matches {
indices[i] = match.index
}
return indices
}
func (m *App) toStart() { func (m *App) toStart() {
if m.screen == prScreen { if m.screen == prScreen {
m.prIndex = 0 m.prIndex = 0
@@ -311,12 +448,90 @@ func (m App) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading…" return "Loading…"
} }
if m.helpVisible {
return m.viewHelp()
}
if m.screen == prScreen { if m.screen == prScreen {
return m.viewPRs() return m.viewPRs()
} }
return m.viewThreads() return m.viewThreads()
} }
type helpBinding struct {
key string
action string
}
func (m App) helpBindings() []helpBinding {
if m.screen == prScreen {
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"},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
}
}
return []helpBinding{
{"h / l", "Focus thread list / detail"},
{"j / k", "Move or scroll focused pane"},
{"↓ / ↑", "Move or scroll focused pane"},
{"g / G", "First / last item"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"tab", "Hide / reveal thread list"},
{"/", "Fuzzy-search file paths"},
{"enter / za", "Fold / expand thread"},
{"b / esc", "Return to pull requests"},
{"r", "Refresh now"},
{"?", "Close this help"},
{"q / ctrl-c", "Quit"},
}
}
func (m App) helpVisibleRows() int {
return max(1, m.height-5)
}
func (m App) helpMaxScroll() int {
return max(0, len(m.helpBindings())-m.helpVisibleRows())
}
func (m App) viewHelp() string {
bindings := m.helpBindings()
visibleRows := m.helpVisibleRows()
start := clamp(m.helpScroll, 0, max(0, len(bindings)-visibleRows))
end := min(len(bindings), start+visibleRows)
contentWidth := max(1, min(70, m.width-4))
keyWidth := min(17, max(8, contentWidth/3))
title := "Pull request picker keys"
if m.screen == threadScreen {
title = "Review thread keys"
}
lines := []string{titleStyle.Render(title)}
for _, binding := range bindings[start:end] {
key := pad(binding.key, keyWidth)
actionWidth := max(1, contentWidth-keyWidth-1)
lines = append(lines, titleStyle.Render(key)+" "+truncate(binding.action, actionWidth))
}
if len(bindings) > visibleRows {
lines = append(lines, dimStyle.Render(fmt.Sprintf(
"%d%d of %d • j/k scroll • ?/esc/q close",
start+1, end, len(bindings),
)))
} else {
lines = append(lines, dimStyle.Render("?/esc/q close"))
}
for i := range lines {
lines[i] = ansi.Truncate(lines[i], contentWidth, "")
}
popup := paneStyle(true).Width(contentWidth).Render(strings.Join(lines, "\n"))
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
}
var ( var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
@@ -335,38 +550,76 @@ func paneStyle(active bool) lipgloss.Style {
} }
func (m App) viewPRs() string { func (m App) viewPRs() string {
header := titleStyle.Render("gh-threads") + " " + m.owner + "/" + m.repo header := titleStyle.Render("gh-threads")
if !m.showAll { if m.owner != "" {
header += dimStyle.Render(" authored by you") header += " " + m.owner + "/" + m.repo
}
if m.showAll {
header += dimStyle.Render(" all open pull requests")
} else {
header += dimStyle.Render(" assigned to you")
} }
lines := []string{header, ""} lines := []string{header, ""}
if m.loading && len(m.prs) == 0 { if m.loading && len(m.prs) == 0 {
lines = append(lines, "Loading open pull requests…") lines = append(lines, "Loading open pull requests…")
} else if len(m.prs) == 0 && m.err == nil { } else if len(m.prs) == 0 && m.err == nil {
lines = append(lines, "No matching open pull requests.") lines = append(lines, "No matching assigned pull requests.")
} }
available := max(1, m.height-6) rows, selectedRow := groupedPRRows(m.prs, m.prIndex)
start := windowStart(m.prIndex, len(m.prs), available) available := max(1, m.height-4)
for i := start; i < min(len(m.prs), start+available); i++ { start := windowStart(selectedRow, len(rows), available)
pr := m.prs[i] for _, row := range rows[start:min(len(rows), start+available)] {
if row.header {
lines = append(lines, titleStyle.Render(row.repository))
continue
}
pr := m.prs[row.prIndex]
draft := "" draft := ""
if pr.IsDraft { if pr.IsDraft {
draft = " DRAFT" draft = " DRAFT"
} }
line := fmt.Sprintf("#%-5d %-*s %2d threads%s", pr.Number, max(10, m.width-34), truncate(pr.Title, max(10, m.width-34)), pr.ReviewCount, draft) titleWidth := max(10, m.width-36)
if i == m.prIndex { line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft)
if row.prIndex == m.prIndex {
line = activeStyle.Render(line) line = activeStyle.Render(line)
} }
lines = append(lines, line) lines = append(lines, line)
} }
return m.frame(lines, "j/k move • enter/l open • g/G top/bottom • r refresh • q quit") return m.frame(lines, "? keys • j/k move • enter open • q quit")
}
type prListRow struct {
repository string
prIndex int
header bool
}
func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
rows := make([]prListRow, 0, len(prs)*2)
selectedRow := 0
lastRepository := ""
for index, pr := range prs {
repository := pr.RepoWithOwner
if repository == "" {
repository = "(unknown repository)"
}
if repository != lastRepository {
rows = append(rows, prListRow{repository: repository, header: true})
lastRepository = repository
}
if index == selected {
selectedRow = len(rows)
}
rows = append(rows, prListRow{repository: repository, prIndex: index})
}
return rows, selectedRow
} }
func (m App) viewThreads() string { func (m App) viewThreads() string {
pr := m.details pr := m.details
header := titleStyle.Render(fmt.Sprintf("#%d %s", pr.Number, truncate(pr.Title, max(10, m.width-10)))) header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12))))
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
people := "assignees: " + joinOrNone(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers) people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
top := []string{header, meta, people} top := []string{header, meta, people}
if pr.ThreadsTruncated { if pr.ThreadsTruncated {
top = append(top, warnStyle.Render("Showing the first 100 review threads.")) top = append(top, warnStyle.Render("Showing the first 100 review threads."))
@@ -389,25 +642,44 @@ func (m App) viewThreads() string {
right := m.threadDetail(rightWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight)
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
} }
return m.frame(append(top, body), "tab list • h/l focus • j/k move/scroll • ctrl-d/u page • za fold • b back • q quit") help := "? keys • h/l focus • j/k move/scroll • b back • q quit"
if m.searching {
help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel"
}
return m.frame(append(top, body), help)
} }
func (m App) threadList(width, height int) string { func (m App) threadList(width, height int) string {
innerWidth := max(1, width-2) innerWidth := max(1, width-2)
innerHeight := max(1, height-2) innerHeight := max(1, height-2)
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d)", len(m.details.Threads)))} lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d)", len(m.details.Threads)))}
if m.searching {
queryWidth := max(1, innerWidth-len("Find file: ")-1)
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
lines = append(lines, titleStyle.Render("Find file: ")+query+"█")
}
if m.loading && len(m.details.Threads) == 0 { if m.loading && len(m.details.Threads) == 0 {
lines = append(lines, "Loading…") lines = append(lines, "Loading…")
} }
available := max(1, innerHeight-1) matches := m.matchingThreadIndices()
start := windowStart(m.threadIndex, len(m.details.Threads), available) selectedPosition := 0
for i := start; i < min(len(m.details.Threads), start+available); i++ { for position, index := range matches {
if index == m.threadIndex {
selectedPosition = position
break
}
}
available := max(1, innerHeight-len(lines))
start := windowStart(selectedPosition, len(matches), available)
if m.searching && len(matches) == 0 {
lines = append(lines, dimStyle.Render("No matching files."))
}
for _, i := range matches[start:min(len(matches), start+available)] {
thread := m.details.Threads[i] thread := m.details.Threads[i]
icon := "●" icon := "●"
if thread.IsResolved { if thread.IsResolved {
icon = "✓" icon = "✓"
} } else if thread.IsOutdated {
if thread.IsOutdated {
icon = "○" icon = "○"
} }
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
@@ -499,7 +771,7 @@ func (m App) detailLines(width int) []detailLine {
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
}) })
if content.Prose != "" { if content.Prose != "" {
for _, commentLine := range strings.Split(wrap(content.Prose, max(10, width-4)), "\n") { for _, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) {
lines = append(lines, detailLine{rail: rail, text: commentLine}) lines = append(lines, detailLine{rail: rail, text: commentLine})
} }
} }
@@ -801,19 +1073,26 @@ func reviewersText(reviewers []Reviewer) string {
} }
items := make([]string, 0, len(reviewers)) items := make([]string, 0, len(reviewers))
for _, reviewer := range reviewers { for _, reviewer := range reviewers {
items = append(items, reviewer.Login+"("+strings.ToLower(reviewer.State)+")") items = append(items,
authorStyle(reviewer.Login).Render("@"+reviewer.Login)+
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
)
} }
return strings.Join(items, ", ") return strings.Join(items, ", ")
} }
func joinOrNone(items []string) string { func handlesText(items []string) string {
if len(items) == 0 { if len(items) == 0 {
return "none" return "none"
} }
return strings.Join(items, ", ") handles := make([]string, 0, len(items))
for _, item := range items {
handles = append(handles, authorStyle(item).Render("@"+item))
}
return strings.Join(handles, ", ")
} }
func indexPR(items []PullRequest, number int) int { func indexPR(items []PullRequest, id string) int {
for i, item := range items { for i, item := range items {
if item.Number == number { if item.ID == id {
return i return i
} }
} }
@@ -858,6 +1137,57 @@ func truncatePath(path string, width int) string {
} }
return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth) return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth)
} }
func fuzzyPathScore(path, query string) (int, bool) {
candidate := []rune(strings.ToLower(path))
terms := strings.Fields(strings.ToLower(query))
if len(terms) == 0 {
return 0, true
}
total := 0
for _, term := range terms {
score, ok := fuzzyTermScore(candidate, []rune(term))
if !ok {
return 0, false
}
total += score
}
return total, true
}
func fuzzyTermScore(candidate, needle []rune) (int, bool) {
if start := strings.Index(string(candidate), string(needle)); start >= 0 {
return 10000 - start*10 - len(candidate), true
}
score, candidateIndex, previous := 0, 0, -2
for _, wanted := range needle {
found := -1
for candidateIndex < len(candidate) {
if candidate[candidateIndex] == wanted {
found = candidateIndex
candidateIndex++
break
}
candidateIndex++
}
if found < 0 {
return 0, false
}
score += 20
if found == previous+1 {
score += 15
}
if found == 0 || strings.ContainsRune("/._-", candidate[found-1]) {
score += 12
}
score -= found
previous = found
}
return score - len(candidate), true
}
func pad(s string, width int) string { func pad(s string, width int) string {
n := width - lipgloss.Width(s) n := width - lipgloss.Width(s)
if n <= 0 { if n <= 0 {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -10,6 +11,23 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
type recordingService struct {
owner string
repo string
number int
}
func (s *recordingService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *recordingService) GetPullRequest(_ context.Context, owner, repo string, number int) (PRDetails, error) {
s.owner, s.repo, s.number = owner, repo, number
return PRDetails{PullRequest: PullRequest{
Owner: owner, Repository: repo, RepoWithOwner: owner + "/" + repo, Number: number,
}}, nil
}
func TestResolvedThreadsStartFolded(t *testing.T) { func TestResolvedThreadsStartFolded(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10) m := NewApp(nil, "o", "r", false, 50, 10)
m.details = PRDetails{PullRequest: PullRequest{Number: 7}} m.details = PRDetails{PullRequest: PullRequest{Number: 7}}
@@ -26,6 +44,21 @@ func TestResolvedThreadsStartFolded(t *testing.T) {
} }
} }
func TestResolvedThreadIconTakesPrecedenceOverOutdated(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.details = PRDetails{Threads: []ReviewThread{{
ID: "done", Path: "main.go", Line: 12, IsResolved: true, IsOutdated: true,
}}}
rendered := ansi.Strip(m.threadList(48, 10))
if !strings.Contains(rendered, "✓ main.go") {
t.Fatalf("resolved and outdated thread did not use check mark:\n%s", rendered)
}
if strings.Contains(rendered, "○ main.go") {
t.Fatalf("outdated icon took precedence over resolved:\n%s", rendered)
}
}
func TestSelectionSurvivesRefresh(t *testing.T) { func TestSelectionSurvivesRefresh(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10) m := NewApp(nil, "o", "r", false, 50, 10)
m.details = PRDetails{ m.details = PRDetails{
@@ -73,6 +106,51 @@ func TestPaneFocusAndNavigation(t *testing.T) {
} }
} }
func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.width, m.height = 70, 24
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
m = updated.(App)
if !m.helpVisible || m.screen != threadScreen {
t.Fatal("? did not open thread help")
}
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") {
t.Fatalf("help was not contextual:\n%s", plain)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.helpVisible || m.screen != threadScreen {
t.Fatal("escape did not close help in place")
}
}
func TestHelpCanScrollInShortTerminal(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.helpVisible = threadScreen, true
m.width, m.height = 46, 9
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")})
m = updated.(App)
if m.helpScroll != m.helpMaxScroll() || m.helpScroll == 0 {
t.Fatalf("help scroll = %d, max = %d", m.helpScroll, m.helpMaxScroll())
}
plain := ansi.Strip(m.View())
if !strings.Contains(plain, "Quit") {
t.Fatalf("last help bindings are not reachable:\n%s", plain)
}
for i, line := range strings.Split(m.View(), "\n") {
if got := ansi.StringWidth(line); got > m.width {
t.Fatalf("help line %d width = %d, terminal width = %d", i, got, m.width)
}
}
}
func TestViewNeverExceedsTerminalWidth(t *testing.T) { func TestViewNeverExceedsTerminalWidth(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen
@@ -103,6 +181,74 @@ func TestTruncatePathPreservesFilename(t *testing.T) {
} }
} }
func TestFuzzyFileSearchRanksAndFiltersPaths(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.searchQuery = "svcusr"
m.details = PRDetails{Threads: []ReviewThread{
{Path: "docs/review-user.md"},
{Path: "internal/service/user.go"},
{Path: "internal/service/team.go"},
}}
matches := m.matchingThreadIndices()
if len(matches) != 1 {
t.Fatalf("matches = %v, want one fuzzy match", matches)
}
if matches[0] != 1 {
t.Fatalf("best match = %q, want internal/service/user.go", m.details.Threads[matches[0]].Path)
}
}
func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
path := "logprep/ng/processor/generic_adder/rule.py"
if _, ok := fuzzyPathScore(path, "ng generic_adder rule"); !ok {
t.Fatalf("space-separated query did not match %q", path)
}
if _, ok := fuzzyPathScore(path, "ng generic_adder missing"); ok {
t.Fatalf("query matched despite a missing term")
}
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got)
}
}
func TestFileSearchCanJumpOrCancel(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.threadIndex = 1
m.details = PRDetails{Threads: []ReviewThread{
{Path: "cmd/main.go"},
{Path: "internal/api/client.go"},
}}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")})
m = updated.(App)
if !m.searching || m.threadIndex != 0 {
t.Fatalf("search did not select cmd/main.go: searching=%v index=%d", m.searching, m.threadIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.searching || m.threadIndex != 1 {
t.Fatalf("cancel did not restore original selection: searching=%v index=%d", m.searching, m.threadIndex)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("cmd")})
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.searching || m.threadIndex != 0 {
t.Fatalf("enter did not keep search jump: searching=%v index=%d", m.searching, m.threadIndex)
}
}
func TestMergeReadyRequiresApproval(t *testing.T) { func TestMergeReadyRequiresApproval(t *testing.T) {
notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"}) notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"})
if strings.Contains(notApproved, "merge: ready") { if strings.Contains(notApproved, "merge: ready") {
@@ -274,3 +420,48 @@ func TestAuthorColorsAreVariedAndDeterministic(t *testing.T) {
t.Fatalf("author palette did not vary: %#v", colors) t.Fatalf("author palette did not vary: %#v", colors)
} }
} }
func TestGroupedPRRowsAddsRepositoryHeadings(t *testing.T) {
prs := []PullRequest{
{RepoWithOwner: "alpha/one", Number: 1},
{RepoWithOwner: "alpha/one", Number: 2},
{RepoWithOwner: "beta/two", Number: 1},
}
rows, selectedRow := groupedPRRows(prs, 2)
if len(rows) != 5 {
t.Fatalf("row count = %d, want 5: %#v", len(rows), rows)
}
if !rows[0].header || rows[0].repository != "alpha/one" ||
!rows[3].header || rows[3].repository != "beta/two" {
t.Fatalf("repository headings are incorrect: %#v", rows)
}
if selectedRow != 4 || rows[selectedRow].prIndex != 2 {
t.Fatalf("selected row = %d, want PR row 4", selectedRow)
}
}
func TestDetailsLoadUsesSelectedPRRepository(t *testing.T) {
service := &recordingService{}
m := NewApp(service, "", "", false, 50, 10*time.Second)
pr := PullRequest{Owner: "other-owner", Repository: "other-repo", Number: 17}
msg := m.loadDetails(pr)().(detailsLoadedMsg)
if service.owner != pr.Owner || service.repo != pr.Repository || service.number != pr.Number {
t.Fatalf("detail request used %s/%s#%d", service.owner, service.repo, service.number)
}
if msg.owner != pr.Owner || msg.repo != pr.Repository || msg.number != pr.Number {
t.Fatalf("detail response identity = %s/%s#%d", msg.owner, msg.repo, msg.number)
}
}
func TestPeopleMetadataUsesColoredHandles(t *testing.T) {
assignees := handlesText([]string{"alice"})
reviewers := reviewersText([]Reviewer{{Login: "bob", State: "APPROVED"}})
if ansi.Strip(assignees) != "@alice" || ansi.Strip(reviewers) != "@bob (approved)" {
t.Fatalf("people metadata = %q / %q", ansi.Strip(assignees), ansi.Strip(reviewers))
}
if authorStyle("alice").GetForeground() != authorColor("alice") ||
authorStyle("bob").GetForeground() != authorColor("bob") {
t.Fatal("people handles do not use the deterministic author color")
}
}

View File

@@ -4,6 +4,9 @@ import "time"
type PullRequest struct { type PullRequest struct {
ID string ID string
Owner string
Repository string
RepoWithOwner string
Number int Number int
Title string Title string
URL string URL string