Improve highlighting and add QoL changes
This commit is contained in:
29
README.md
29
README.md
@@ -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.
|
||||||
|
|||||||
66
github.go
66
github.go
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
24
go.mod
@@ -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
54
go.sum
@@ -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
15
main.go
@@ -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
145
markdown.go
Normal 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
88
markdown_test.go
Normal 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
400
tui.go
@@ -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 {
|
||||||
|
|||||||
191
tui_test.go
191
tui_test.go
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user