From 547cd123da2ae12bcad9337b7ff14adff1cc8b40 Mon Sep 17 00:00:00 2001 From: Pablu Date: Mon, 27 Jul 2026 14:28:32 +0200 Subject: [PATCH] Improve highlighting and add QoL changes --- README.md | 29 +++- github.go | 94 +++++++---- github_test.go | 51 +++++- go.mod | 24 ++- go.sum | 54 +++++-- main.go | 19 ++- markdown.go | 145 +++++++++++++++++ markdown_test.go | 88 +++++++++++ tui.go | 400 ++++++++++++++++++++++++++++++++++++++++++----- tui_test.go | 191 ++++++++++++++++++++++ types.go | 3 + 11 files changed, 989 insertions(+), 109 deletions(-) create mode 100644 markdown.go create mode 100644 markdown_test.go diff --git a/README.md b/README.md index 1da2d3e..27e4000 100644 --- a/README.md +++ b/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, 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. diff --git a/github.go b/github.go index eb2bd8d..872f0b1 100644 --- a/github.go +++ b/github.go @@ -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}) { - nodes { + 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,52 +109,72 @@ 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 { - Nodes []struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - URL string `json:"url"` - IsDraft bool `json:"isDraft"` - UpdatedAt time.Time `json:"updatedAt"` - Author *struct { + Search struct { + Nodes []struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + IsDraft bool `json:"isDraft"` + UpdatedAt time.Time `json:"updatedAt"` + 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:"author"` - ReviewThreads struct { - TotalCount int `json:"totalCount"` - } `json:"reviewThreads"` - } `json:"nodes"` - } `json:"pullRequests"` - } `json:"repository"` + } `json:"owner"` + } `json:"repository"` + ReviewThreads struct { + TotalCount int `json:"totalCount"` + } `json:"reviewThreads"` + } `json:"nodes"` + } `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), }, diff --git a/github_test.go b/github_test.go index 0b9e6f0..d2438e3 100644 --- a/github_test.go +++ b/github_test.go @@ -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"}]}`)) diff --git a/go.mod b/go.mod index b5ea1f0..0ade1b1 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index d6cff89..88ff987 100644 --- a/go.sum +++ b/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/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= diff --git a/main.go b/main.go index a9028b2..33ea77b 100644 --- a/main.go +++ b/main.go @@ -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() - parts := strings.Split(*repo, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - exitf("--repo owner/name (or GH_REPO) is required") + var owner, name string + if *repo != "" { + parts := strings.Split(*repo, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + 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) } diff --git a/markdown.go b/markdown.go new file mode 100644 index 0000000..afa9f3f --- /dev/null +++ b/markdown.go @@ -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 +} diff --git a/markdown_test.go b/markdown_test.go new file mode 100644 index 0000000..8fd0f4e --- /dev/null +++ b/markdown_test.go @@ -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) + } + } +} diff --git a/tui.go b/tui.go index 5cbe27b..7eb98ee 100644 --- a/tui.go +++ b/tui.go @@ -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 { diff --git a/tui_test.go b/tui_test.go index de60ccb..7e74640 100644 --- a/tui_test.go +++ b/tui_test.go @@ -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") + } +} diff --git a/types.go b/types.go index 10b4b59..5855305 100644 --- a/types.go +++ b/types.go @@ -4,6 +4,9 @@ import "time" type PullRequest struct { ID string + Owner string + Repository string + RepoWithOwner string Number int Title string URL string