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,
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. 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
@@ -13,13 +16,13 @@ Requires Go 1.24+ and an authenticated GitHub CLI:
```sh
go install .
gh auth login
gh-threads --repo owner/repository
gh-threads
```
To run directly from a source checkout instead:
```sh
go run . --repo owner/repository
go run .
```
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
variables are also supported.
By default the PR picker only includes open PRs authored by the authenticated
user. Pass `--all` to include every open PR:
By default the PR picker searches all repositories for open PRs assigned to the
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
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 |
| `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 |
| `enter` / `l` | Open a PR |
| `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
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
displayed as readable wrapped text rather than fully rendered Markdown.
thread; the UI warns when the thread list is truncated. GitHub features which
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 = `
query PullRequests($owner: String!, $name: String!, $limit: Int!) {
query PullRequests($query: String!, $limit: Int!) {
viewer { login }
repository(owner: $owner, name: $name) {
pullRequests(first: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
search(query: $query, type: ISSUE, first: $limit) {
nodes {
... on PullRequest {
id number title url isDraft updatedAt
author { login }
repository {
name
nameWithOwner
owner { login }
}
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) {
if showAll && owner == "" {
return nil, errors.New("--all requires --repo to avoid an unbounded global search")
}
var data struct {
Viewer struct {
Login string `json:"login"`
} `json:"viewer"`
Repository *struct {
PullRequests struct {
Search struct {
Nodes []struct {
ID string `json:"id"`
Number int `json:"number"`
@@ -120,36 +127,54 @@ func (c *GitHubClient) ListPullRequests(ctx context.Context, owner, name string,
Author *struct {
Login string `json:"login"`
} `json:"author"`
Repository struct {
Name string `json:"name"`
NameWithOwner string `json:"nameWithOwner"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
} `json:"repository"`
ReviewThreads struct {
TotalCount int `json:"totalCount"`
} `json:"reviewThreads"`
} `json:"nodes"`
} `json:"pullRequests"`
} `json:"repository"`
} `json:"search"`
}
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
}
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))
for _, node := range data.Repository.PullRequests.Nodes {
prs := make([]PullRequest, 0, len(data.Search.Nodes))
for _, node := range data.Search.Nodes {
if node.Repository.NameWithOwner == "" {
continue
}
author := "[ghost]"
if node.Author != nil {
author = node.Author.Login
}
mine := author == data.Viewer.Login
if !showAll && !mine {
continue
}
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,
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
}
@@ -265,7 +290,8 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
}
details := PRDetails{
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,
ReviewCount: len(node.ReviewThreads.Nodes),
},

View File

@@ -9,17 +9,28 @@ import (
"testing"
)
func TestListPullRequestsFiltersToViewer(t *testing.T) {
func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
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{
"viewer": map[string]any{"login": "zam"},
"repository": map[string]any{"pullRequests": 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{"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}},
}}},
"search": map[string]any{"nodes": []any{
map[string]any{
"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()
@@ -29,11 +40,39 @@ func TestListPullRequestsFiltersToViewer(t *testing.T) {
if err != nil {
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)
}
}
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) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))

24
go.mod
View File

@@ -5,26 +5,36 @@ go 1.24.0
require (
github.com/alecthomas/chroma/v2 v2.20.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.10.2
)
require (
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/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/dlclark/regexp2 v1.11.5 // 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-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/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
github.com/yuin/goldmark v1.7.13 // 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/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-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/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/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
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/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
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/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/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.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
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/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/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
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/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
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/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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
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() {
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")
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)")
endpoint = flag.String("endpoint", "https://api.github.com/graphql", "GitHub GraphQL endpoint")
)
flag.Parse()
var owner, name string
if *repo != "" {
parts := strings.Split(*repo, "/")
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 {
exitf("--limit must be between 1 and 100")
@@ -36,7 +43,7 @@ func main() {
}
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 {
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"
"fmt"
"hash/fnv"
"sort"
"strings"
"time"
@@ -32,6 +33,8 @@ type prsLoadedMsg struct {
err error
}
type detailsLoadedMsg struct {
owner string
repo string
number int
details PRDetails
err error
@@ -58,6 +61,11 @@ type App struct {
err error
lastRefresh time.Time
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 {
@@ -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 {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
details, err := m.service.GetPullRequest(ctx, m.owner, m.repo, number)
return detailsLoadedMsg{number: number, details: details, err: err}
details, err := m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number)
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 {
m.loading = true
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())
}
@@ -112,17 +123,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err
return m, nil
}
selected := 0
selected := ""
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
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.err = nil
m.lastRefresh = time.Now()
case detailsLoadedMsg:
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
}
if msg.err != nil {
@@ -153,9 +172,66 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
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" {
return m, tea.Quit
}
if k == "?" {
m.helpVisible, m.helpScroll = true, 0
return m, nil
}
if m.pendingZ {
m.pendingZ = false
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
}
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":
if m.loading {
return m, nil
}
m.loading = true
if m.screen == threadScreen {
return m, m.loadDetails(m.details.Number)
return m, m.loadDetails(m.details.PullRequest)
}
return m, m.loadPRs()
case "j", "down":
@@ -214,7 +295,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
return m, m.loadDetails(m.details.Number)
m.searching, m.searchQuery = false, ""
return m, m.loadDetails(m.details.PullRequest)
}
if m.screen == threadScreen {
m.focus = threadDetailPane
@@ -229,7 +311,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
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 {
thread := m.details.Threads[m.threadIndex]
@@ -239,6 +322,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "b", "esc":
if m.screen == threadScreen {
m.screen, m.err, m.loading = prScreen, nil, true
m.searching, m.searchQuery = false, ""
return m, m.loadPRs()
}
}
@@ -254,6 +338,59 @@ func (m *App) move(delta int) {
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() {
if m.screen == prScreen {
m.prIndex = 0
@@ -311,12 +448,90 @@ func (m App) View() string {
if m.width == 0 {
return "Loading…"
}
if m.helpVisible {
return m.viewHelp()
}
if m.screen == prScreen {
return m.viewPRs()
}
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 (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
@@ -335,38 +550,76 @@ func paneStyle(active bool) lipgloss.Style {
}
func (m App) viewPRs() string {
header := titleStyle.Render("gh-threads") + " " + m.owner + "/" + m.repo
if !m.showAll {
header += dimStyle.Render(" authored by you")
header := titleStyle.Render("gh-threads")
if m.owner != "" {
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, ""}
if m.loading && len(m.prs) == 0 {
lines = append(lines, "Loading open pull requests…")
} 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)
start := windowStart(m.prIndex, len(m.prs), available)
for i := start; i < min(len(m.prs), start+available); i++ {
pr := m.prs[i]
rows, selectedRow := groupedPRRows(m.prs, m.prIndex)
available := max(1, m.height-4)
start := windowStart(selectedRow, len(rows), available)
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 := ""
if pr.IsDraft {
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)
if i == m.prIndex {
titleWidth := max(10, m.width-36)
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)
}
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 {
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))
people := "assignees: " + joinOrNone(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
top := []string{header, meta, people}
if pr.ThreadsTruncated {
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)
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 {
innerWidth := max(1, width-2)
innerHeight := max(1, height-2)
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 {
lines = append(lines, "Loading…")
}
available := max(1, innerHeight-1)
start := windowStart(m.threadIndex, len(m.details.Threads), available)
for i := start; i < min(len(m.details.Threads), start+available); i++ {
matches := m.matchingThreadIndices()
selectedPosition := 0
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]
icon := "●"
if thread.IsResolved {
icon = "✓"
}
if thread.IsOutdated {
} else if thread.IsOutdated {
icon = "○"
}
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")),
})
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})
}
}
@@ -801,19 +1073,26 @@ func reviewersText(reviewers []Reviewer) string {
}
items := make([]string, 0, len(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, ", ")
}
func joinOrNone(items []string) string {
func handlesText(items []string) string {
if len(items) == 0 {
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 {
if item.Number == number {
if item.ID == id {
return i
}
}
@@ -858,6 +1137,57 @@ func truncatePath(path string, width int) string {
}
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 {
n := width - lipgloss.Width(s)
if n <= 0 {

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -10,6 +11,23 @@ import (
"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) {
m := NewApp(nil, "o", "r", false, 50, 10)
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) {
m := NewApp(nil, "o", "r", false, 50, 10)
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) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
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) {
notApproved := reviewAndMergeState(PRDetails{Mergeable: "MERGEABLE", ReviewDecision: "REVIEW_REQUIRED"})
if strings.Contains(notApproved, "merge: ready") {
@@ -274,3 +420,48 @@ func TestAuthorColorsAreVariedAndDeterministic(t *testing.T) {
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 {
ID string
Owner string
Repository string
RepoWithOwner string
Number int
Title string
URL string