Compare commits

...

10 Commits

33 changed files with 11762 additions and 373 deletions

295
README.md
View File

@@ -1,22 +1,26 @@
# gh-threads # diple
A read-only terminal UI for people receiving GitHub pull-request reviews. It A 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 and a scrollable PR dashboard with the description, branches,
reviewer/assignee state, and the latest commit's check rollup. Resolved threads review state, merge conflicts and affected files, checks, people, labels,
start folded. GitHub suggestion blocks are shown as syntax-highlighted milestone, activity, change statistics, thread totals, submitted reviews, and
remove/add previews. Comments render GitHub Flavored Markdown, including quoted the PR conversation. Review threads and comments are paginated rather than
replies, inline and fenced code, lists and tasks, links, tables, emphasis, silently stopping at the first page. The
strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the thread viewer includes highlighted diff hunks, comment authors, and read-only
background. reaction counts on individual comments. Resolved threads start folded. GitHub suggestion blocks are shown as
syntax-highlighted remove/add previews. Comments and PR descriptions render
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
alerts. The current PR is refreshed in the background.
## Install and run ## Install and run
Requires Go 1.24+ and an authenticated GitHub CLI: Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
```sh ```sh
go install . go install .
gh auth login gh auth login
gh-threads diple
``` ```
To run directly from a source checkout instead: To run directly from a source checkout instead:
@@ -37,36 +41,40 @@ authenticated user and groups the results by repository. Use `--repo` to limit
the picker to one repository: the picker to one repository:
```sh ```sh
gh-threads --repo owner/repository diple --repo owner/repository
``` ```
With a repository selected, pass `--all` to include every open PR in that With a repository selected, pass `--all` to include every open PR in that
repository: repository:
```sh ```sh
gh-threads --repo owner/repository --all --poll 15s diple --repo owner/repository --all --poll 15s
``` ```
GitHub Enterprise Server can be used after authenticating that host: GitHub Enterprise Server can be used after authenticating that host:
```sh ```sh
gh auth login --hostname github.example.com gh auth login --hostname github.example.com
gh-threads --repo owner/repository \ diple --repo owner/repository \
--endpoint https://github.example.com/api/graphql --endpoint https://github.example.com/api/graphql
``` ```
## Configuration ## Configuration
The optional TOML configuration is loaded from The optional TOML configuration is loaded from
`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the `$DIPLE_CONFIG`, `$XDG_CONFIG_HOME/diple/config.toml`, or the operating
operating system's user configuration directory at `gh-threads/config.toml`. system's user configuration directory at `diple/config.toml`.
On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS, On Linux this is normally `~/.config/diple/config.toml`. On macOS,
`~/Library/Application Support/gh-threads/config.toml` is preferred, with `~/Library/Application Support/diple/config.toml` is preferred, with
`~/.config/gh-threads/config.toml` automatically used as a fallback when it `~/.config/diple/config.toml` automatically used as a fallback when it
exists. exists.
For migration, `GH_THREADS_CONFIG` and existing `gh-threads` configuration or
cache directories remain fallback locations when their new `diple`
counterparts do not yet exist.
```toml ```toml
theme = "dark" # "dark" or "light" theme = "dark" # dark, light, high-contrast, or no-color
refresh_interval = "10s" refresh_interval = "10s"
repository = "" # optional owner/repository default repository = "" # optional owner/repository default
show_all = false # requires repository show_all = false # requires repository
@@ -76,6 +84,8 @@ endpoint = "https://api.github.com/graphql"
[display] [display]
fold_resolved = true fold_resolved = true
thread_list_width_percent = 33 # 20-60 thread_list_width_percent = 33 # 20-60
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
compact_reviews = true # aggregate submitted review history
[paths] [paths]
scroll = false scroll = false
@@ -86,37 +96,258 @@ scroll_interval = "350ms" # minimum 50ms
# resolved threads remain in "resolved" even when they are also outdated. # resolved threads remain in "resolved" even when they are also outdated.
status_order = ["unresolved", "outdated", "resolved"] status_order = ["unresolved", "outdated", "resolved"]
within_status = "file" # "file" or "timestamp" (oldest first) within_status = "file" # "file" or "timestamp" (oldest first)
[cache]
enabled = true # instant stale view plus offline fallback
max_age = "168h" # 7 days; 0 means no age limit
directory = "" # defaults to the OS user cache directory
max_entries = 200 # bounded oldest-first pruning; 10-10000
[editing]
mode = "vim" # "vim" or "standard"; description field only for now
[keybindings.general]
quit = ["q", "ctrl+c"]
help = ["?", "f1"]
refresh = ["r"]
back = ["b", "esc"]
confirm = ["y"]
reject = ["n", "esc"]
[keybindings.navigation]
# Shared by the picker, dashboard, thread panes, help, and Vim Normal/Visual modes.
down = ["j", "down"]
up = ["k", "up"]
left = ["h", "left"]
right = ["l", "right"]
first = ["g"]
last = ["G"]
page_down = ["ctrl+d", "pgdown"]
page_up = ["ctrl+u", "pgup"]
[keybindings.views]
open = ["enter", "l"]
dashboard = ["d"]
health = ["H"]
edit = ["e"]
auto_merge = ["a"]
merge_now = ["M"]
toggle_list = ["tab"]
[keybindings.threads]
search = ["/"]
clear_filter = ["F"]
next_unread = ["n"]
previous_unread = ["N"]
reply = ["c"]
resolve = ["R"]
toggle = ["enter"]
fold_prefix = ["z"]
fold_toggle = ["a"]
[keybindings.input]
cancel = ["esc"]
submit = ["ctrl+s"]
newline = ["enter"]
delete_backward = ["backspace"]
delete_forward = ["delete"]
clear = ["ctrl+u"]
next_field = ["tab"]
previous_field = ["shift+tab"]
next_completion = ["ctrl+n"]
previous_completion = ["ctrl+p"]
line_start = ["home", "ctrl+a"]
line_end = ["end", "ctrl+e"]
[keybindings.vim]
insert = ["i"]
append = ["a"]
insert_line_start = ["I"]
append_line_end = ["A"]
open_below = ["o"]
open_above = ["O"]
replace_character = ["s"]
visual = ["v"]
visual_line = ["V"]
selection_other_end = ["o"]
yank = ["y"]
delete = ["d", "x", "delete"]
delete_before = ["X", "backspace"]
paste = ["p"]
line_start = ["0", "home"]
first_non_blank = ["^"]
line_end = ["$", "end"]
word_forward = ["w"]
big_word_forward = ["W"]
word_backward = ["b"]
big_word_backward = ["B"]
word_end = ["e"]
big_word_end = ["E"]
go_prefix = ["g"]
find_forward = ["f"]
find_backward = ["F"]
till_forward = ["t"]
till_backward = ["T"]
repeat_find = [";"]
repeat_find_reverse = [","]
``` ```
Every command binding accepts one or more Bubble Tea key names. Omitted
settings retain their defaults, while an explicitly configured action replaces
its default keys. Printable keys remain text in Insert mode, reply drafts, and
search queries; command bindings apply in the appropriate non-text context.
The contextual `?` popup and compact screen footers use the configured keys.
Configuration loading also checks each active context independently. A key may
be reused on unrelated screens, but assigning it to two different actions that
can be active together reports the context and both conflicting actions.
When cached data exists, the picker and PR details are rendered immediately
from that snapshot while a live GitHub refresh runs in the background. Cached
screens are labelled with their save time and are replaced automatically when
fresh data arrives. Core PR and review data is rendered before check
annotations and conflict-file analysis finish. A failed subsection keeps its
last complete value, is marked partial, and does not discard the rest of a
successful refresh. Check annotations are fetched separately only for failed
checks and are reused by immutable check ID.
The cache uses separate JSON files for the picker and each visited PR. Cache
content is hashed before writing: unchanged responses do not rewrite their
files. Their modification time is touched at most once per day (or half the
configured maximum age, when shorter) so recently validated snapshots remain
usable without writing on every poll. Changed files are replaced atomically,
and oldest cache entries are pruned at the configured bound. Read state and
recoverable reply/metadata drafts use versioned, atomic files beside the
configuration.
Polling adapts to GitHub's reported rate-limit budget. It backs off as the
remaining budget gets low, honors server retry windows, and adds jitter to
avoid synchronized clients. Opening another PR or starting another refresh
cancels the superseded request.
GitHub's public APIs report whether a PR conflicts but do not expose its
conflicting file paths. For conflicting PRs only, `diple` performs a
read-only `git merge-tree` analysis in a temporary bare repository. It never
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
without a local repository behave identically. The analysis fetches the exact
remote base branch and pull-request head ref using the existing GitHub
credential. Results are memoized by the base and head commit, and failed scans
are retried after one minute.
Command-line flags override the configuration. `GH_REPO` overrides the Command-line flags override the configuration. `GH_REPO` overrides the
configured repository when `--repo` is not provided. The corresponding flags configured repository when `--repo` is not provided. The corresponding flags
include `--config`, `--theme`, `--poll`, `--fold-resolved`, include `--config`, `--theme`, `--poll`, `--fold-resolved`,
`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean `--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
settings can be disabled explicitly, for example `--path-scroll=false`. `--path-scroll`, and `--path-scroll-interval`, plus `--cache`,
`--cache-max-age`, `--cache-dir`, and `--editor-mode`.
Boolean settings can be disabled explicitly, for
example `--compact-reviews=false`.
## Keys With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its
review threads and `d` opens the dashboard only when requested. Set
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
threads instead.
Compact reviews aggregate submission counts by state and author. Reviews with
a written summary retain a compact one-line body, while timestamps and commit
SHAs are omitted. Set `compact_reviews = false` to restore the complete review
history and metadata.
## Default keys
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `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 items or scroll the dashboard/focused detail |
| `?` | Show contextual keybinding help | | `?` | Show contextual keybinding help |
| `/` | Fuzzy-search thread file paths | | `H` | Open application health and diagnostics |
| `` / `↓` | Choose a fuzzy-search match | | `d` | Open the current pull request dashboard |
| `e` | Edit the current PR title, target branch, and description from its dashboard |
| `a` | Enable or disable auto-merge from the dashboard |
| `M` | Merge now when GitHub reports that all represented requirements are satisfied |
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
| `F` | Clear active thread filters |
| `n` / `N` | Next / previous thread with a new update |
| `c` | Compose a reply to the selected thread |
| `R` | Resolve or unresolve the selected thread |
| `ctrl-p` / `ctrl-n` | Choose the previous / next fuzzy-search or branch-completion match |
| `g` / `G` | First / last item | | `g` / `G` | First / last item |
| `enter` / `l` | Open a PR | | `enter` / `l` | Open the selected PR dashboard or its review threads |
| `enter` | Toggle the selected review thread | | `enter` | Toggle the selected review thread |
| `za` | Toggle the selected thread | | `za` | Toggle the selected thread |
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists | | `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
| `tab` | Hide or reveal the thread list | | `tab` | Hide or reveal the thread list |
| `b` / `esc` | Return to the PR picker | | `b` / `esc` | Return to the previous screen |
| `r` | Refresh now | | `r` | Refresh now |
| `q` | Quit | | `q` | Quit |
The Health modal reports the interactive loop, configuration, GitHub API,
rate-limit budget and reset/retry time, disk cache, unread-state persistence,
draft recovery, core PR data, and secondary enrichment. Session warnings and
errors are retained there with their component and timestamp. Long diagnostics
wrap to the modal width. Refresh activity occupies a stable informational row
so polling does not reorder the report. Press `H` from the picker, dashboard,
or thread view; `b` or `esc` closes it without changing the underlying scroll
position.
The reply composer appears inline beneath the selected thread so its code and
comments remain visible while writing. It supports multiple lines: `enter`
inserts a newline, `ctrl-s` opens the rendered confirmation preview, and `esc`
cancels. Replies and resolution changes require an explicit `y` confirmation.
Write keys remain disabled for cached snapshots, during refreshes, and whenever
GitHub does not grant the corresponding capability.
Auto-merge and immediate merge actions are available from the dashboard and
always require confirmation. The selected method is the repository's first
available method in `squash`, `merge`, then `rebase` preference order. Both
mutations include the currently displayed head commit OID, so a force-push or
new commit prevents a stale merge. “Merge now” is gated for drafts, conflicts,
required reviews, required checks, unresolved required conversations, closed
PRs, and branches that require a merge queue; GitHub performs the final
permission and mergeability validation.
The dashboard editor works with raw Markdown so template checklists can be
updated directly. The active line is highlighted without inserting a
layout-changing block character. It opens with the description focused;
`tab` and `shift-tab` move between the description, title, and target branch.
When the target branch is focused, repository branches are recommended using
the typed text, likely branch names, the current/default branch, and each
branch's latest commit time. The list updates as you type. Use `ctrl-p` and
`ctrl-n` to select the previous or next result, then `tab` or `enter` to complete it;
pressing `tab` again moves to the description.
With the default `editing.mode = "vim"`, the description starts in Normal mode.
It supports `hjkl`, `0`, `^`, `$`, `gg`, `G`, `w`/`W`, `b`/`B`, `e`/`E`,
`f`/`F`/`t`/`T` with `;` and `,`, `i`/`a`/`I`/`A`, `o`/`O`, `s`, and
`x`/`X`. `s` removes the character under the cursor and enters Insert mode.
Soft-wrapped rows behave as visual editor lines for vertical and line-local
motions, but do not add newlines to the Markdown submitted to GitHub.
`ctrl-d` and `ctrl-u` move the cursor and viewport down or up by half a page,
including while extending a Visual selection.
`v` starts character-wise Visual mode and `V` starts visual-line selection;
`d` or `x` deletes the selection, `y` copies it to the system clipboard, and
`p` pastes from the system clipboard. Normal mode uses a block cursor, while
Insert mode uses the terminal's hardware bar cursor at the boundary between
characters without hiding or shifting either character.
The description retains its raw Markdown while headings, emphasis, inline
code, links, quote markers, and HTML comments receive syntax highlighting.
Highlighting consists only of zero-width terminal styling and cannot alter
wrapping, selection, clipboard contents, cursor offsets, or submitted text.
`esc` returns from Insert to Normal mode; a second `esc` cancels the editor.
Set `editing.mode = "standard"` for direct insertion with arrow,
`home`, and `end` navigation. Title and target branch remain standard inputs
in either mode. `ctrl-s` opens an explicit confirmation. If the title,
description, or target branch changes remotely while the editor is open,
submission is blocked rather than overwriting the newer metadata.
## Current scope ## Current scope
The application is intentionally read-only. GitHub's GraphQL API currently The application can reply to review threads, resolve or unresolve them, update
limits this client to the first 100 review threads and first 100 comments per the PR title, target branch, and description, enable or disable auto-merge, and
thread; the UI warns when the thread list is truncated. GitHub features which merge an eligible PR immediately. Comment reactions remain read-only. Other
write operations remain disabled. The dashboard shows the capability gate,
including why each action is unavailable. Read state
persists beside the configuration, and recent PR data is cached for offline
fallback. Check contexts and annotations are paginated. GitHub features which
depend on server-side context, such as unfurling issue references or displaying depend on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal. uploaded images, are represented textually in the terminal. See
[`TODO.md`](TODO.md) for remaining read-only work and write-support preparation.

126
TODO.md Normal file
View File

@@ -0,0 +1,126 @@
# TODO
This list reflects the current implementation: paginated review threads,
thread comments, conversation comments, reviews, timeline events, checks, and
annotations; cached read-only snapshots; persistent unread state; contextual
keybindings; thread replies and resolution changes; and pull-request metadata
editing are already implemented.
## Completed resilience work
- Refreshes render core data before annotations and conflict analysis. A
failed paginated subsection is marked partial and keeps the last complete
value while other sections continue updating.
- Polling uses GitHub rate-limit and retry headers, backs off at low remaining
budgets, and adds jitter. Failed-check annotations are cached by immutable
check ID.
- Superseded list, detail, and enrichment requests are canceled as newer
navigation or refresh work starts.
- Reply and PR-metadata drafts are stored in a versioned, atomic, permission
restricted file and restored after cancellation or restart.
- Unread state and disk cache are versioned and atomically replaced. Corrupt
persistence is reported by the health screen rather than silently trusted.
Cache writes are content-addressed, bounded, oldest-first pruned, and cached
branch recommendations remain available offline.
- Editor character motions, deletion, selection, wrapping, and clipboard
ranges operate on grapheme boundaries, with regression coverage for
combining marks, full-width characters, variation selectors, and joined
emoji.
- The health screen reports component status, rate-limit state, persistence
paths, partial data, and the session's wrapped warning/error history.
## High value workflow additions
- Open the current PR, thread comment, submitted review, check, annotation,
commit, or source location in a browser.
- Copy URLs, commit SHAs, file paths, branch names, rendered comment text, and
raw Markdown through explicit contextual actions.
- Add a dedicated changed-files/check-details view. It should make the complete
PR diff and check annotations inspectable even when no review thread exists
at that location.
- Add navigation to the next thread by status, file, author, or failed check,
not only the next unread update.
- Persist the selected PR/thread, scroll anchors, focused pane, hidden-list
state, folded threads, active filter, and pane width between runs.
- Make the picker scope configurable: assigned PRs, viewer-authored PRs,
review-requested PRs, subscribed PRs, or a union of those scopes. Clearly
label why each PR appears.
- Add saved/named thread filters and search history for repeated review
workflows.
- Distinguish local unread state from GitHub notification state, and optionally
integrate with GitHub notifications without silently marking remote
notifications as read.
- Make health events individually selectable and copyable, and retain
per-subsection last-success timestamps across refreshes.
## Data completeness and compatibility
- Paginate or explicitly mark truncation for the remaining fixed-size
connections: assignees, labels, review requests, latest reviews, repository
rulesets, and rules within a ruleset.
- Model pending reviews, minimized comments, deleted comments/users, edited
timestamps, and explicit reply relationships.
- Preserve enough team-reviewer identity to distinguish teams with the same
display name and to generate a correct browser target.
- Represent partial permissions per comment and conversation item, not only
aggregate PR/thread capabilities.
- Verify ruleset, branch-protection, merge-queue, deployment, and check behavior
against supported GitHub Enterprise Server versions. Degrade individual
fields when a schema feature is unavailable instead of rejecting the whole
PR query.
- Improve uploaded-image and attachment handling with optional terminal image
protocols or an open/download action while keeping a textual fallback.
## Write roadmap
- Add fuzzy multi-select editors for requested reviewers, assignees, labels,
and milestone. Support adding, removing, and clearing values with an explicit
before/after confirmation.
- Add top-level PR conversation replies and editing/deleting the viewer's own
comments. Fetch and enforce per-comment update/delete permissions.
- Add reaction add/remove actions while retaining the current read-only counts.
- Support submitting pending reviews and review summaries, including approve,
comment, and request-changes states.
- Support applying GitHub suggestions only after validating the original
commit/head SHA and showing the exact resulting patch. Define behavior for
multiple suggestions, conflicts, dirty Git/Jujutsu workspaces, and remote
application.
- Add draft/ready-for-review and close/reopen actions.
- Add explicit merge-method selection and merge-queue enqueue/dequeue actions.
Auto-merge toggling and guarded immediate merge are implemented with
stale-head protection and destructive confirmation.
- Define consistent optimistic-update and rollback behavior for every mutation.
Preserve drafts and server responses when a post-mutation refresh fails.
## UX and configurability
- Add diff-view settings for context size, tab width, whitespace visibility,
line-number style, syntax theme, and whether outdated/resolved context starts
collapsed.
- Add an optional command palette so configured actions remain discoverable
even when their key is forgotten or unbound.
- Audit screen-reader behavior beyond no-color/high-contrast themes, including
focus announcements, status symbols, popup ordering, and live refreshes.
- Add optional mouse selection/scrolling without changing keyboard-first
defaults.
- Make relative/absolute timestamp display and timezone configurable.
## Testing and maintainability
- Finish consolidating key dispatch, help, compact footers, and contextual
conflict validation into one action registry. Context validation is already
enforced, but declaration order and help descriptions remain separate.
- Coalesce unread-state persistence through the same delayed flush mechanism
used for drafts if future read-state actions make writes frequent.
- Add recorded GraphQL fixtures for GitHub.com and supported GitHub Enterprise
Server versions, including partial errors, rate limits, deleted actors, team
reviewers, mixed legacy statuses, and very large PRs.
- Add golden terminal snapshots across narrow/wide sizes, all themes,
configurable keys, Unicode-heavy content, editor modes, partial-data states,
and cached/live transitions.
- Add end-to-end mutation tests covering permission changes, stale head SHAs,
offline transitions, server success followed by refresh failure, and draft
recovery.
- Split the large GitHub-fetch and TUI update/render modules by data source and
screen once doing so removes duplicated state transitions; keep shared
behavior in small typed helpers rather than introducing a framework.

213
branch_completion.go Normal file
View File

@@ -0,0 +1,213 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
)
type branchSuggestion struct {
branch RepositoryBranch
score int
}
func rankBranchSuggestions(
branches []RepositoryBranch,
query, current string,
now time.Time,
) []branchSuggestion {
query = strings.TrimSpace(strings.ToLower(query))
current = strings.ToLower(current)
suggestions := make([]branchSuggestion, 0, len(branches))
for _, branch := range branches {
name := strings.ToLower(branch.Name)
matchScore := 0
if query != "" {
var matches bool
matchScore, matches = fuzzyTermScore([]rune(name), []rune(query))
if !matches {
continue
}
switch {
case name == query:
matchScore += 50000
case strings.HasPrefix(name, query):
matchScore += 30000
case branchSegmentHasPrefix(name, query):
matchScore += 20000
}
}
score := matchScore * 10
if branch.IsDefault {
score += 9000
}
if name == current {
score += 7000
}
switch name {
case "main", "master":
score += 3500
case "develop", "development", "dev":
score += 2500
}
if strings.HasPrefix(name, "release/") || strings.HasPrefix(name, "release-") {
score += 1800
}
score += branchFreshnessScore(branch.UpdatedAt, now)
suggestions = append(suggestions, branchSuggestion{branch: branch, score: score})
}
sort.SliceStable(suggestions, func(i, j int) bool {
if suggestions[i].score != suggestions[j].score {
return suggestions[i].score > suggestions[j].score
}
if !suggestions[i].branch.UpdatedAt.Equal(suggestions[j].branch.UpdatedAt) {
return suggestions[i].branch.UpdatedAt.After(suggestions[j].branch.UpdatedAt)
}
return strings.ToLower(suggestions[i].branch.Name) <
strings.ToLower(suggestions[j].branch.Name)
})
return suggestions
}
func branchSegmentHasPrefix(name, query string) bool {
for _, segment := range strings.FieldsFunc(name, func(value rune) bool {
return strings.ContainsRune("/._-", value)
}) {
if strings.HasPrefix(segment, query) {
return true
}
}
return false
}
func branchFreshnessScore(updatedAt, now time.Time) int {
if updatedAt.IsZero() {
return 0
}
age := now.Sub(updatedAt)
if age < 0 {
age = 0
}
days := int(age / (24 * time.Hour))
return max(0, 3000-min(days, 3000))
}
func branchAgeLabel(updatedAt, now time.Time) string {
if updatedAt.IsZero() {
return "age unknown"
}
age := now.Sub(updatedAt)
if age < time.Hour {
return "updated recently"
}
if age < 24*time.Hour {
return fmt.Sprintf("updated %dh ago", int(age/time.Hour))
}
days := int(age / (24 * time.Hour))
if days < 30 {
return fmt.Sprintf("updated %dd ago", days)
}
months := days / 30
if months < 24 {
return fmt.Sprintf("updated %dmo ago", months)
}
return fmt.Sprintf("updated %dy ago", days/365)
}
func (m App) branchSuggestions() []branchSuggestion {
suggestions := rankBranchSuggestions(
m.prEditBranches,
m.prEditEditors[prEditBaseField].Text,
m.prEditOriginal.BaseRef,
time.Now(),
)
const maximumVisibleSuggestions = 6
if len(suggestions) > maximumVisibleSuggestions {
suggestions = suggestions[:maximumVisibleSuggestions]
}
return suggestions
}
func (m *App) moveBranchSuggestion(delta int) {
suggestions := m.branchSuggestions()
if len(suggestions) == 0 {
m.prEditBranchIndex = 0
return
}
m.prEditBranchIndex = (m.prEditBranchIndex + delta + len(suggestions)) % len(suggestions)
}
func (m *App) completeBranchSuggestion() bool {
suggestions := m.branchSuggestions()
if len(suggestions) == 0 {
return false
}
index := clamp(m.prEditBranchIndex, 0, len(suggestions)-1)
name := suggestions[index].branch.Name
editor := &m.prEditEditors[prEditBaseField]
if editor.Text == name {
return false
}
editor.Text = name
editor.Cursor = len([]rune(name))
m.prEditBranchIndex = 0
m.err = nil
return true
}
func (m App) branchCompletionLines(width int) []string {
width = max(1, width)
if m.prEditBranchesLoading {
return []string{dimStyle.Render(" loading repository branches…")}
}
if m.prEditBranchesError != "" {
message := " branch recommendations unavailable: " + m.prEditBranchesError
wrapped := ansi.Hardwrap(ansi.Wordwrap(message, width, ""), width, false)
var lines []string
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, warnStyle.Render(line))
}
return lines
}
suggestions := m.branchSuggestions()
if len(suggestions) == 0 {
return []string{dimStyle.Render(" no matching repository branches")}
}
lines := []string{dimStyle.Render(fmt.Sprintf(
" %s choose • %s complete • %s again advances",
primaryCombinedKeyLabel(
m.keybindings.Input.PreviousCompletion,
m.keybindings.Input.NextCompletion,
),
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.NextField),
))}
now := time.Now()
for index, suggestion := range suggestions {
prefix := " "
if index == clamp(m.prEditBranchIndex, 0, len(suggestions)-1) {
prefix = " ▶ "
}
suffix := branchAgeLabel(suggestion.branch.UpdatedAt, now)
if suggestion.branch.IsDefault {
suffix = "default • " + suffix
}
if suggestion.branch.Name == m.prEditOriginal.BaseRef {
suffix = "current • " + suffix
}
available := max(1, width-len([]rune(prefix))-len([]rune(suffix))-2)
name := ansi.Truncate(suggestion.branch.Name, available, "…")
line := prefix + name + strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) + dimStyle.Render(suffix)
if strings.HasPrefix(prefix, " ▶") {
line = titleStyle.Render(prefix+name) +
strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) +
dimStyle.Render(suffix)
}
lines = append(lines, line)
}
return lines
}

104
branch_completion_test.go Normal file
View File

@@ -0,0 +1,104 @@
package main
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
func TestBranchSuggestionsPreferLikelyAndFreshBranches(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
branches := []RepositoryBranch{
{Name: "old-feature", UpdatedAt: now.AddDate(-2, 0, 0)},
{Name: "recent-feature", UpdatedAt: now.Add(-time.Hour)},
{Name: "main", UpdatedAt: now.AddDate(0, -6, 0), IsDefault: true},
}
suggestions := rankBranchSuggestions(branches, "", "main", now)
if len(suggestions) != 3 ||
suggestions[0].branch.Name != "main" ||
suggestions[1].branch.Name != "recent-feature" {
t.Fatalf("unexpected ranking: %#v", suggestions)
}
}
func TestBranchSuggestionsReactToFuzzyInput(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
branches := []RepositoryBranch{
{Name: "feature/relation", UpdatedAt: now},
{Name: "release/2.0", UpdatedAt: now.AddDate(-1, 0, 0)},
{Name: "main", UpdatedAt: now, IsDefault: true},
}
suggestions := rankBranchSuggestions(branches, "rel", "main", now)
if len(suggestions) != 2 || suggestions[0].branch.Name != "release/2.0" {
t.Fatalf("prefix match was not preferred: %#v", suggestions)
}
suggestions = rankBranchSuggestions(branches, "frel", "main", now)
if len(suggestions) != 1 || suggestions[0].branch.Name != "feature/relation" {
t.Fatalf("fuzzy match failed: %#v", suggestions)
}
}
func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
service := &recordingPRService{branches: []RepositoryBranch{
{Name: "main", IsDefault: true},
{Name: "release/2.0"},
{Name: "release/1.0"},
}}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
Number: 1, Title: "Title",
},
BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true},
}
command := m.startPREdit()
if command == nil {
t.Fatal("opening the editor did not request branches")
}
updated, _ := m.Update(command())
m = updated.(App)
m.prEditField = prEditBaseField
m.prEditEditors[prEditBaseField] = newTextEditor("release", false)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlN})
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" &&
got != "release/1.0" {
t.Fatalf("tab did not complete selected branch: %q", got)
}
if m.prEditField != prEditBaseField {
t.Fatalf("completion moved away from target branch: field=%d", m.prEditField)
}
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if m.prEditField != prEditBodyField {
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
}
}
func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.width = 80
m.prEditField = prEditBaseField
m.prEditOriginal = PullRequestMetadata{BaseRef: "main"}
m.prEditEditors[prEditTitleField] = newTextEditor("Title", false)
m.prEditEditors[prEditBaseField] = newTextEditor("rel", false)
m.prEditEditors[prEditBodyField] = newTextEditor("", true)
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab / enter complete") {
t.Fatalf("branch suggestions missing:\n%s", view)
}
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {
t.Fatalf("unknown branch validation error = %v", err)
}
}

464
cache.go Normal file
View File

@@ -0,0 +1,464 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type cacheEnvelope[T any] struct {
Version int `json:"version,omitempty"`
SavedAt time.Time `json:"saved_at"`
ContentHash string `json:"content_hash,omitempty"`
Value T `json:"value"`
}
type CachedGitHubService struct {
remote GitHubService
dir string
maxAge time.Duration
maxEntries int
health healthTracker
}
type cachedSnapshotService interface {
CachedPullRequests(string, string, int, bool) ([]PullRequest, error)
CachedPullRequest(string, string, int) (PRDetails, error)
}
type liveGitHubService interface {
LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error)
LivePullRequest(context.Context, string, string, int) (PRDetails, error)
}
const cacheSchemaVersion = 1
func NewCachedGitHubService(
remote GitHubService, dir string, maxAge time.Duration, configuredMaxEntries ...int,
) *CachedGitHubService {
maxEntries := 200
if len(configuredMaxEntries) > 0 && configuredMaxEntries[0] > 0 {
maxEntries = configuredMaxEntries[0]
}
return &CachedGitHubService{
remote: remote, dir: dir, maxAge: maxAge, maxEntries: maxEntries,
}
}
func (c *CachedGitHubService) CachedPullRequests(
owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
var cached cacheEnvelope[[]PullRequest]
savedAt, err := c.read(c.pullRequestsKey(owner, repo, limit, showAll), &cached)
if err != nil {
return nil, err
}
cached.SavedAt = savedAt
for i := range cached.Value {
cached.Value[i].FromCache, cached.Value[i].CachedAt = true, cached.SavedAt
}
return cached.Value, nil
}
func (c *CachedGitHubService) CachedPullRequest(owner, repo string, number int) (PRDetails, error) {
var cached cacheEnvelope[PRDetails]
savedAt, err := c.read(c.pullRequestKey(owner, repo, number), &cached)
if err != nil {
return PRDetails{}, err
}
cached.SavedAt = savedAt
cached.Value.FromCache, cached.Value.CachedAt = true, cached.SavedAt
return cached.Value, nil
}
func (c *CachedGitHubService) ListPullRequests(
ctx context.Context, owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
prs, err := c.LivePullRequests(ctx, owner, repo, limit, showAll)
if err == nil {
return prs, nil
}
cached, cacheErr := c.CachedPullRequests(owner, repo, limit, showAll)
if cacheErr != nil {
return nil, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr)
}
return cached, nil
}
func (c *CachedGitHubService) GetPullRequest(
ctx context.Context, owner, repo string, number int,
) (PRDetails, error) {
details, err := c.LivePullRequest(ctx, owner, repo, number)
if err == nil {
return details, nil
}
cached, cacheErr := c.CachedPullRequest(owner, repo, number)
if cacheErr != nil {
return PRDetails{}, fmt.Errorf("%w (cache unavailable: %v)", err, cacheErr)
}
return cached, nil
}
func (c *CachedGitHubService) LivePullRequests(
ctx context.Context, owner, repo string, limit int, showAll bool,
) ([]PullRequest, error) {
prs, err := c.remote.ListPullRequests(ctx, owner, repo, limit, showAll)
if err != nil {
return nil, err
}
for i := range prs {
prs[i].FromCache, prs[i].CachedAt = false, time.Time{}
}
_ = c.write(c.pullRequestsKey(owner, repo, limit, showAll), prs)
return prs, nil
}
func (c *CachedGitHubService) LivePullRequest(
ctx context.Context, owner, repo string, number int,
) (PRDetails, error) {
details, err := c.remote.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return PRDetails{}, err
}
details.FromCache, details.CachedAt = false, time.Time{}
_ = c.write(c.pullRequestKey(owner, repo, number), details)
return details, nil
}
func (c *CachedGitHubService) SetThreadResolved(
ctx context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
writer, ok := c.remote.(GitHubWriteService)
if !ok {
return ReviewThread{}, errors.New("GitHub service does not support write actions")
}
return writer.SetThreadResolved(ctx, threadID, resolved)
}
func (c *CachedGitHubService) ReplyToThread(
ctx context.Context, threadID, body string,
) (ReviewComment, error) {
writer, ok := c.remote.(GitHubWriteService)
if !ok {
return ReviewComment{}, errors.New("GitHub service does not support write actions")
}
return writer.ReplyToThread(ctx, threadID, body)
}
func (c *CachedGitHubService) UpdatePullRequest(
ctx context.Context,
pullRequestID string,
update PullRequestMetadata,
) (PullRequestMetadata, error) {
writer, ok := c.remote.(GitHubPullRequestWriteService)
if !ok {
return PullRequestMetadata{}, errors.New("GitHub service does not support pull request updates")
}
return writer.UpdatePullRequest(ctx, pullRequestID, update)
}
func (c *CachedGitHubService) SetPullRequestAutoMerge(
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
) (*AutoMergeRequest, error) {
writer, ok := c.remote.(GitHubMergeService)
if !ok {
return nil, errors.New("GitHub service does not support auto-merge")
}
return writer.SetPullRequestAutoMerge(
ctx, pullRequestID, expectedHeadOID, mergeMethod, enabled,
)
}
func (c *CachedGitHubService) MergePullRequest(
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string,
) (PullRequestMergeResult, error) {
writer, ok := c.remote.(GitHubMergeService)
if !ok {
return PullRequestMergeResult{}, errors.New("GitHub service does not support merging")
}
return writer.MergePullRequest(ctx, pullRequestID, expectedHeadOID, mergeMethod)
}
func (c *CachedGitHubService) ListBranches(
ctx context.Context, owner, repo string,
) ([]RepositoryBranch, error) {
service, ok := c.remote.(GitHubBranchService)
if !ok {
return nil, errors.New("GitHub service does not support listing branches")
}
branches, err := service.ListBranches(ctx, owner, repo)
if err == nil {
_ = c.write(c.branchesKey(owner, repo), branches)
return branches, nil
}
var cached cacheEnvelope[[]RepositoryBranch]
if _, cacheErr := c.read(c.branchesKey(owner, repo), &cached); cacheErr == nil {
c.health.set(HealthComponent{
Name: "branch cache", Level: healthWarning,
Summary: "using cached branches", Detail: err.Error(), UpdatedAt: time.Now(),
})
return cached.Value, nil
}
return nil, err
}
func (c *CachedGitHubService) EnrichPullRequest(
ctx context.Context, details PRDetails,
) PRDetailsEnrichment {
service, ok := c.remote.(GitHubEnrichmentService)
if !ok {
return PRDetailsEnrichment{
Owner: details.Owner, Repository: details.Repository, Number: details.Number,
HeadOID: details.HeadOID,
Issues: []DataIssue{{
Component: "PR enrichment",
Message: "GitHub service does not support secondary PR data",
}},
}
}
return service.EnrichPullRequest(ctx, details)
}
func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string {
return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll)
}
func (c *CachedGitHubService) pullRequestKey(owner, repo string, number int) string {
return fmt.Sprintf("pr:%s/%s:%d", owner, repo, number)
}
func (c *CachedGitHubService) branchesKey(owner, repo string) string {
return fmt.Sprintf("branches:%s/%s", owner, repo)
}
func (c *CachedGitHubService) file(key string) string {
sum := sha256.Sum256([]byte(key))
return filepath.Join(c.dir, hex.EncodeToString(sum[:])+".json")
}
func (c *CachedGitHubService) write(key string, value any) (resultErr error) {
defer func() {
component := HealthComponent{
Name: "disk cache", Level: healthOK, Summary: "cache write succeeded",
Detail: c.dir, UpdatedAt: time.Now(),
}
if resultErr != nil {
component.Level = healthWarning
component.Summary = resultErr.Error()
}
c.health.set(component)
}()
if err := os.MkdirAll(c.dir, 0o700); err != nil {
return err
}
valueData, err := json.Marshal(value)
if err != nil {
return err
}
sum := sha256.Sum256(valueData)
contentHash := hex.EncodeToString(sum[:])
target := c.file(key)
if existing, err := os.ReadFile(target); err == nil {
var metadata struct {
ContentHash string `json:"content_hash"`
}
if json.Unmarshal(existing, &metadata) == nil && metadata.ContentHash == contentHash {
if info, statErr := os.Stat(target); statErr == nil &&
time.Since(info.ModTime()) >= c.cacheTouchInterval() {
now := time.Now()
_ = os.Chtimes(target, now, now)
}
return nil
}
}
data, err := json.Marshal(cacheEnvelope[any]{
Version: cacheSchemaVersion, SavedAt: time.Now(),
ContentHash: contentHash, Value: value,
})
if err != nil {
return err
}
temp, err := os.CreateTemp(c.dir, ".cache-*")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(name, target); err != nil {
return err
}
return c.prune()
}
func (c *CachedGitHubService) prune() error {
if c.maxEntries <= 0 {
return nil
}
entries, err := os.ReadDir(c.dir)
if err != nil {
return err
}
type cacheFile struct {
path string
modTime time.Time
}
var files []cacheFile
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, cacheFile{
path: filepath.Join(c.dir, entry.Name()), modTime: info.ModTime(),
})
}
sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
for len(files) > c.maxEntries {
if err := os.Remove(files[0].path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
files = files[1:]
}
return nil
}
func (c *CachedGitHubService) cacheTouchInterval() time.Duration {
interval := 24 * time.Hour
if c.maxAge > 0 && c.maxAge/2 < interval {
interval = c.maxAge / 2
}
return interval
}
func (c *CachedGitHubService) read(key string, target any) (
saved time.Time, resultErr error,
) {
defer func() {
component := HealthComponent{
Name: "disk cache", Level: healthOK, Summary: "cache read succeeded",
Detail: c.dir, UpdatedAt: time.Now(),
}
if resultErr != nil {
component.Level = healthWarning
component.Summary = resultErr.Error()
}
c.health.set(component)
}()
path := c.file(key)
data, err := os.ReadFile(path)
if err != nil {
return time.Time{}, err
}
if err := json.Unmarshal(data, target); err != nil {
return time.Time{}, err
}
var metadata struct {
Version int `json:"version"`
SavedAt time.Time `json:"saved_at"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return time.Time{}, err
}
if metadata.Version != 0 && metadata.Version != cacheSchemaVersion {
return time.Time{}, fmt.Errorf(
"unsupported cache schema version %d", metadata.Version,
)
}
savedAt := metadata.SavedAt
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(savedAt) {
savedAt = info.ModTime()
}
if c.maxAge > 0 && time.Since(savedAt) > c.maxAge {
return time.Time{}, errors.New("cached data expired")
}
return savedAt, nil
}
func (c *CachedGitHubService) HealthReport() []HealthComponent {
components := c.health.report()
if provider, ok := c.remote.(healthProvider); ok {
components = append(components, provider.HealthReport()...)
}
return components
}
func (c *CachedGitHubService) RateLimit() RateLimitSnapshot {
if provider, ok := c.remote.(healthProvider); ok {
return provider.RateLimit()
}
return RateLimitSnapshot{}
}
type readStateStore struct {
path string
Data map[string]readPRState `json:"pull_requests"`
loadErr error
}
const readStateSchemaVersion = 1
type readStateEnvelope struct {
Version int `json:"version"`
PullRequests map[string]readPRState `json:"pull_requests"`
}
type readPRState struct {
Initialized bool `json:"initialized"`
Threads map[string]bool `json:"threads"`
Comments map[string]bool `json:"comments"`
}
func loadReadState(path string) *readStateStore {
store := &readStateStore{path: path, Data: make(map[string]readPRState)}
data, err := os.ReadFile(path)
if err == nil {
var envelope readStateEnvelope
if json.Unmarshal(data, &envelope) == nil &&
envelope.Version == readStateSchemaVersion &&
envelope.PullRequests != nil {
store.Data = envelope.PullRequests
} else {
// Backward-compatible migration from the original unversioned map.
if migrationErr := json.Unmarshal(data, &store.Data); migrationErr != nil {
store.Data = make(map[string]readPRState)
store.loadErr = fmt.Errorf("read state is corrupt: %w", migrationErr)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
store.loadErr = err
}
return store
}
func (s *readStateStore) save() error {
if s == nil || s.path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
return atomicWriteJSON(s.path, readStateEnvelope{
Version: readStateSchemaVersion, PullRequests: s.Data,
}, 0o600)
}

248
cache_test.go Normal file
View File

@@ -0,0 +1,248 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type switchService struct {
prs []PullRequest
details PRDetails
err error
}
type countingCachedService struct {
liveDetailsCalls int
cachedDetailsCalls int
}
func (s *countingCachedService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) {
return PRDetails{}, nil
}
func (s *countingCachedService) LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) LivePullRequest(context.Context, string, string, int) (PRDetails, error) {
s.liveDetailsCalls++
return PRDetails{}, nil
}
func (s *countingCachedService) CachedPullRequests(string, string, int, bool) ([]PullRequest, error) {
return nil, nil
}
func (s *countingCachedService) CachedPullRequest(string, string, int) (PRDetails, error) {
s.cachedDetailsCalls++
return PRDetails{}, nil
}
func (s *switchService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) {
return s.prs, s.err
}
func (s *switchService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) {
return s.details, s.err
}
func TestCachedServiceFallsBackToRecentReadData(t *testing.T) {
remote := &switchService{
prs: []PullRequest{{ID: "pr", Owner: "o", Repository: "r", Number: 1}},
details: PRDetails{PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}},
}
service := NewCachedGitHubService(remote, t.TempDir(), time.Hour)
if _, err := service.ListPullRequests(context.Background(), "o", "r", 50, false); err != nil {
t.Fatal(err)
}
if _, err := service.GetPullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
remote.err = errors.New("offline")
prs, err := service.ListPullRequests(context.Background(), "o", "r", 50, false)
if err != nil || len(prs) != 1 || !prs[0].FromCache || prs[0].CachedAt.IsZero() {
t.Fatalf("cached PR list = %#v, error = %v", prs, err)
}
details, err := service.GetPullRequest(context.Background(), "o", "r", 1)
if err != nil || !details.FromCache || details.CachedAt.IsZero() {
t.Fatalf("cached details = %#v, error = %v", details, err)
}
}
func TestReadStateSurvivesRestartWithUnreadComment(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
settings := defaultAppSettings()
settings.ReadState = loadReadState(path)
m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
initial := PRDetails{
PullRequest: PullRequest{ID: "pr"},
Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{{ID: "old"}}}},
}
m.trackThreadUpdates(initial)
updated := initial
updated.Threads[0].Comments = append(updated.Threads[0].Comments, ReviewComment{ID: "new"})
m.trackThreadUpdates(updated)
if !m.unreadThreads["thread"] {
t.Fatal("new comment was not unread before restart")
}
settings.ReadState = loadReadState(path)
restarted := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings)
restarted.trackThreadUpdates(updated)
if !restarted.unreadThreads["thread"] {
t.Fatal("unread comment was lost across restart")
}
}
func TestCorruptReadStateIsReportedAndRecoveredEmpty(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
if err := os.WriteFile(path, []byte("{broken"), 0o600); err != nil {
t.Fatal(err)
}
store := loadReadState(path)
if store.loadErr == nil || len(store.Data) != 0 {
t.Fatalf("corrupt state recovery = error %v data %#v", store.loadErr, store.Data)
}
}
func TestCachedPickerSnapshotStaysVisibleWhileLiveRefreshContinues(t *testing.T) {
m := NewApp(nil, "", "", false, 50, time.Second)
m.loading = true
cachedAt := time.Now().Add(-time.Hour)
updated, _ := m.Update(prsLoadedMsg{
cached: true,
prs: []PullRequest{{
ID: "cached", Number: 1, FromCache: true, CachedAt: cachedAt,
}},
})
m = updated.(App)
if len(m.prs) != 1 || !m.prs[0].FromCache || !m.loading {
t.Fatalf("cached snapshot was not shown during refresh: %#v", m)
}
updated, _ = m.Update(prsLoadedMsg{prs: []PullRequest{{ID: "live", Number: 2}}})
m = updated.(App)
if len(m.prs) != 1 || m.prs[0].ID != "live" || m.loading {
t.Fatalf("live response did not replace cached snapshot: %#v", m)
}
}
func TestRoutineRefreshDoesNotReplayCachedDetails(t *testing.T) {
service := &countingCachedService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
pr := PullRequest{Owner: "o", Repository: "r", Number: 1}
if msg := m.loadDetails(pr, false)(); msg == nil {
t.Fatal("live refresh returned no message")
}
if service.liveDetailsCalls != 1 || service.cachedDetailsCalls != 0 {
t.Fatalf("routine refresh calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls)
}
msg := m.loadDetails(pr, true)()
batch, ok := msg.(tea.BatchMsg)
if !ok {
t.Fatalf("initial load command returned %T, want tea.BatchMsg", msg)
}
for _, command := range batch {
_ = command()
}
if service.liveDetailsCalls != 2 || service.cachedDetailsCalls != 1 {
t.Fatalf("initial load calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls)
}
}
func TestCacheDoesNotRewriteUnchangedContent(t *testing.T) {
remote := &switchService{details: PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1, Title: "same"},
}}
service := NewCachedGitHubService(remote, t.TempDir(), time.Hour)
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
path := service.file(service.pullRequestKey("o", "r", 1))
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Millisecond)
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("unchanged cache content was rewritten")
}
remote.details.Title = "changed"
if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil {
t.Fatal(err)
}
changed, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(after, changed) {
t.Fatal("changed cache content was not persisted")
}
}
func TestCachePrunesOldestEntriesAtConfiguredBound(t *testing.T) {
remote := &switchService{}
service := NewCachedGitHubService(remote, t.TempDir(), time.Hour, 2)
for number := 1; number <= 3; number++ {
remote.details = PRDetails{PullRequest: PullRequest{
ID: "pr-" + fmtInt(number), Owner: "o", Repository: "r", Number: number,
}}
if _, err := service.LivePullRequest(context.Background(), "o", "r", number); err != nil {
t.Fatal(err)
}
time.Sleep(time.Millisecond)
}
entries, err := os.ReadDir(service.dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("cache entries = %d, want 2", len(entries))
}
if _, err := service.CachedPullRequest("o", "r", 1); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("oldest cache entry error = %v, want not exist", err)
}
}
func TestCacheRejectsUnknownSchemaVersion(t *testing.T) {
service := NewCachedGitHubService(&switchService{}, t.TempDir(), time.Hour)
path := service.file(service.pullRequestKey("o", "r", 1))
envelope := map[string]any{
"version": 999, "saved_at": time.Now(),
"value": PRDetails{PullRequest: PullRequest{ID: "pr"}},
}
data, err := json.Marshal(envelope)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := service.CachedPullRequest("o", "r", 1); err == nil ||
!strings.Contains(err.Error(), "unsupported cache schema") {
t.Fatalf("schema error = %v", err)
}
}

View File

@@ -34,11 +34,16 @@ type Config struct {
Display DisplayConfig `toml:"display"` Display DisplayConfig `toml:"display"`
Paths PathConfig `toml:"paths"` Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"` Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"`
KeyBindings KeyBindings `toml:"keybindings"`
} }
type DisplayConfig struct { type DisplayConfig struct {
FoldResolved bool `toml:"fold_resolved"` FoldResolved bool `toml:"fold_resolved"`
ThreadListWidthPercent int `toml:"thread_list_width_percent"` ThreadListWidthPercent int `toml:"thread_list_width_percent"`
DashboardMode string `toml:"dashboard_mode"`
CompactReviews bool `toml:"compact_reviews"`
} }
type PathConfig struct { type PathConfig struct {
@@ -51,6 +56,17 @@ type ThreadConfig struct {
WithinStatus string `toml:"within_status"` WithinStatus string `toml:"within_status"`
} }
type CacheConfig struct {
Enabled bool `toml:"enabled"`
MaxAge configDuration `toml:"max_age"`
Directory string `toml:"directory"`
MaxEntries int `toml:"max_entries"`
}
type EditingConfig struct {
Mode string `toml:"mode"`
}
func defaultConfig() Config { func defaultConfig() Config {
return Config{ return Config{
Theme: "dark", Theme: "dark",
@@ -60,6 +76,8 @@ func defaultConfig() Config {
Display: DisplayConfig{ Display: DisplayConfig{
FoldResolved: true, FoldResolved: true,
ThreadListWidthPercent: 33, ThreadListWidthPercent: 33,
DashboardMode: "hotkey",
CompactReviews: true,
}, },
Paths: PathConfig{ Paths: PathConfig{
Scroll: false, Scroll: false,
@@ -69,35 +87,55 @@ func defaultConfig() Config {
StatusOrder: []string{"unresolved", "outdated", "resolved"}, StatusOrder: []string{"unresolved", "outdated", "resolved"},
WithinStatus: "file", WithinStatus: "file",
}, },
Cache: CacheConfig{
Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}, MaxEntries: 200,
},
Editing: EditingConfig{Mode: "vim"},
KeyBindings: defaultKeyBindings(),
} }
} }
func configPath() (string, error) { func configPath() (string, error) {
if path := os.Getenv("DIPLE_CONFIG"); path != "" {
return path, nil
}
// Preserve the old override during the rename so existing scripts do not
// silently start with a fresh configuration.
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" { if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
return path, nil return path, nil
} }
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
return filepath.Join(base, "gh-threads", "config.toml"), nil return firstExistingOrDefault(
filepath.Join(base, "diple", "config.toml"),
filepath.Join(base, "gh-threads", "config.toml"),
), nil
} }
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return "", fmt.Errorf("find user config directory: %w", err) return "", fmt.Errorf("find user config directory: %w", err)
} }
preferred := filepath.Join(base, "gh-threads", "config.toml") preferred := filepath.Join(base, "diple", "config.toml")
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", fmt.Errorf("find home directory: %w", err) return "", fmt.Errorf("find home directory: %w", err)
} }
fallback := filepath.Join(home, ".config", "gh-threads", "config.toml") dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
return existingConfigPath(preferred, fallback), nil legacyPreferred := filepath.Join(base, "gh-threads", "config.toml")
legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml")
return firstExistingOrDefault(
preferred, dotConfig, legacyPreferred, legacyDotConfig,
), nil
} }
func existingConfigPath(preferred, fallback string) string { func existingConfigPath(preferred, fallback string) string {
if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) { return firstExistingOrDefault(preferred, fallback)
return preferred }
}
if _, err := os.Stat(fallback); err == nil { func firstExistingOrDefault(preferred string, alternatives ...string) string {
return fallback for _, candidate := range append([]string{preferred}, alternatives...) {
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
return candidate
}
} }
return preferred return preferred
} }
@@ -123,15 +161,15 @@ func loadConfig(path string, required bool) (Config, error) {
func validateConfig(config Config) error { func validateConfig(config Config) error {
switch config.Theme { switch config.Theme {
case "dark", "light": case "dark", "light", "no-color", "high-contrast":
default: default:
return fmt.Errorf("theme must be dark or light") return fmt.Errorf("theme must be dark or light")
} }
if config.RefreshInterval.Duration < 2*time.Second { if config.RefreshInterval.Duration < 2*time.Second {
return fmt.Errorf("refresh_interval must be at least 2s") return fmt.Errorf("refresh_interval must be at least 2s")
} }
if config.Limit < 1 || config.Limit > 100 { if config.Limit < 1 || config.Limit > 1000 {
return fmt.Errorf("limit must be between 1 and 100") return fmt.Errorf("limit must be between 1 and 1000")
} }
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond { if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
return fmt.Errorf("paths.scroll_interval must be at least 50ms") return fmt.Errorf("paths.scroll_interval must be at least 50ms")
@@ -139,6 +177,11 @@ func validateConfig(config Config) error {
if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 {
return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60")
} }
switch config.Display.DashboardMode {
case "intermediate", "hotkey":
default:
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
}
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
return err return err
} }
@@ -150,9 +193,34 @@ func validateConfig(config Config) error {
if config.ShowAll && config.Repository == "" { if config.ShowAll && config.Repository == "" {
return fmt.Errorf("show_all requires repository") return fmt.Errorf("show_all requires repository")
} }
if config.Cache.MaxAge.Duration < 0 {
return fmt.Errorf("cache.max_age must not be negative")
}
if config.Cache.MaxEntries < 10 || config.Cache.MaxEntries > 10000 {
return fmt.Errorf("cache.max_entries must be between 10 and 10000")
}
switch config.Editing.Mode {
case "standard", "vim":
default:
return fmt.Errorf("editing.mode must be standard or vim")
}
if err := validateKeyBindings(config.KeyBindings); err != nil {
return err
}
return nil return nil
} }
func defaultCacheDir() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("find user cache directory: %w", err)
}
return firstExistingOrDefault(
filepath.Join(base, "diple"),
filepath.Join(base, "gh-threads"),
), nil
}
func validateThreadStatusOrder(order []string) error { func validateThreadStatusOrder(order []string) error {
if len(order) != 3 { if len(order) != 3 {
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once") return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")

View File

@@ -18,7 +18,9 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
if got.Theme != want.Theme || if got.Theme != want.Theme ||
got.RefreshInterval.Duration != want.RefreshInterval.Duration || got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
got.Paths.Scroll != want.Paths.Scroll || got.Paths.Scroll != want.Paths.Scroll ||
got.Display.FoldResolved != want.Display.FoldResolved { got.Display.FoldResolved != want.Display.FoldResolved ||
got.Display.CompactReviews != want.Display.CompactReviews ||
got.Editing.Mode != "vim" {
t.Fatalf("defaults = %#v, want %#v", got, want) t.Fatalf("defaults = %#v, want %#v", got, want)
} }
} }
@@ -36,6 +38,8 @@ endpoint = "https://github.example.com/api/graphql"
[display] [display]
fold_resolved = false fold_resolved = false
thread_list_width_percent = 45 thread_list_width_percent = 45
dashboard_mode = "hotkey"
compact_reviews = false
[paths] [paths]
scroll = true scroll = true
@@ -44,6 +48,18 @@ scroll_interval = "125ms"
[threads] [threads]
status_order = ["resolved", "unresolved", "outdated"] status_order = ["resolved", "unresolved", "outdated"]
within_status = "timestamp" within_status = "timestamp"
[cache]
enabled = false
max_age = "48h"
directory = "/tmp/diple-cache"
[editing]
mode = "standard"
[keybindings.navigation]
down = ["ctrl+j"]
up = ["ctrl+k"]
` `
if err := os.WriteFile(path, []byte(content), 0o600); err != nil { if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -58,13 +74,69 @@ within_status = "timestamp"
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
got.Display.DashboardMode != "hotkey" ||
got.Display.CompactReviews ||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" { got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/diple-cache" ||
got.Editing.Mode != "standard" ||
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
t.Fatalf("config = %#v", got) t.Fatalf("config = %#v", got)
} }
} }
func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) {
config := defaultConfig()
config.KeyBindings.Navigation.Down = nil
err := validateConfig(config)
if err == nil || !strings.Contains(err.Error(), "keybindings.navigation.down") {
t.Fatalf("empty binding error = %v", err)
}
}
func TestValidateConfigRejectsKeyConflictsInTheSameContext(t *testing.T) {
tests := []struct {
name string
change func(*Config)
context string
actions []string
}{
{
name: "thread navigation and reply",
change: func(config *Config) {
config.KeyBindings.Threads.Reply = []string{"j"}
},
context: "review threads",
actions: []string{"down", "reply"},
},
{
name: "vim motion and cancel",
change: func(config *Config) {
config.KeyBindings.Input.Cancel = []string{"b"}
},
context: "Vim Normal mode",
actions: []string{"cancel", "word_backward"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config := defaultConfig()
test.change(&config)
err := validateConfig(config)
if err == nil || !strings.Contains(err.Error(), test.context) {
t.Fatalf("conflict error = %v", err)
}
for _, action := range test.actions {
if !strings.Contains(err.Error(), action) {
t.Fatalf("conflict error does not name %q: %v", action, err)
}
}
})
}
}
func TestLoadConfigRejectsUnknownSettings(t *testing.T) { func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml") path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil { if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil {
@@ -77,20 +149,33 @@ func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
} }
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) { func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
t.Setenv("GH_THREADS_CONFIG", "/tmp/custom-gh-threads.toml") t.Setenv("DIPLE_CONFIG", "/tmp/custom-diple.toml")
t.Setenv("GH_THREADS_CONFIG", "")
got, err := configPath() got, err := configPath()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got != "/tmp/custom-gh-threads.toml" { if got != "/tmp/custom-diple.toml" {
t.Fatalf("config path = %q", got) t.Fatalf("config path = %q", got)
} }
} }
func TestConfigPathHonorsLegacyEnvironmentOverride(t *testing.T) {
t.Setenv("DIPLE_CONFIG", "")
t.Setenv("GH_THREADS_CONFIG", "/tmp/legacy-gh-threads.toml")
got, err := configPath()
if err != nil {
t.Fatal(err)
}
if got != "/tmp/legacy-gh-threads.toml" {
t.Fatalf("legacy config path = %q", got)
}
}
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) { func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
root := t.TempDir() root := t.TempDir()
preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml") preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml")
fallback := filepath.Join(root, ".config", "gh-threads", "config.toml") fallback := filepath.Join(root, ".config", "diple", "config.toml")
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -112,14 +197,30 @@ func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
} }
} }
func TestFirstExistingConfigPathFallsBackToLegacyName(t *testing.T) {
root := t.TempDir()
current := filepath.Join(root, "diple", "config.toml")
legacy := filepath.Join(root, "gh-threads", "config.toml")
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("theme = \"dark\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := firstExistingOrDefault(current, legacy); got != legacy {
t.Fatalf("migration config path = %q, want %q", got, legacy)
}
}
func TestConfigPathHonorsXDGConfigHome(t *testing.T) { func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
t.Setenv("GH_THREADS_CONFIG", "") t.Setenv("GH_THREADS_CONFIG", "")
t.Setenv("DIPLE_CONFIG", "")
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config") t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
got, err := configPath() got, err := configPath()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := "/tmp/xdg-config/gh-threads/config.toml" want := "/tmp/xdg-config/diple/config.toml"
if got != want { if got != want {
t.Fatalf("config path = %q, want %q", got, want) t.Fatalf("config path = %q, want %q", got, want)
} }
@@ -145,3 +246,19 @@ func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) {
t.Fatal("unknown within-status ordering was accepted") t.Fatal("unknown within-status ordering was accepted")
} }
} }
func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
config := defaultConfig()
config.Display.DashboardMode = "sometimes"
if err := validateConfig(config); err == nil {
t.Fatal("unknown dashboard mode was accepted")
}
}
func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
config := defaultConfig()
config.Editing.Mode = "emacs"
if err := validateConfig(config); err == nil {
t.Fatal("unknown editor mode was accepted")
}
}

190
conflicts.go Normal file
View File

@@ -0,0 +1,190 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
)
type conflictFileLoader func(
context.Context, string, int, string, string, string, string,
) ([]string, error)
type conflictFileResult struct {
files []string
err error
checkedAt time.Time
}
func (c *GitHubClient) loadConflictFiles(
ctx context.Context,
repositoryURL string,
number int,
baseRef, baseOID, headOID string,
) ([]string, error) {
key := strings.Join([]string{repositoryURL, baseOID, headOID}, "\x00")
c.conflictMu.Lock()
cached, ok := c.conflictCache[key]
c.conflictMu.Unlock()
if ok && (cached.err == nil || time.Since(cached.checkedAt) < time.Minute) {
return append([]string(nil), cached.files...), cached.err
}
files, err := c.conflicts(ctx, repositoryURL, number, baseRef, baseOID, headOID, c.token)
result := conflictFileResult{
files: append([]string(nil), files...), err: err, checkedAt: time.Now(),
}
c.conflictMu.Lock()
c.conflictCache[key] = result
c.conflictMu.Unlock()
return files, err
}
func analyzeConflictFiles(
ctx context.Context,
repositoryURL string,
number int,
baseRef, _, _, token string,
) ([]string, error) {
if repositoryURL == "" || baseRef == "" || number <= 0 {
return nil, errors.New("missing repository merge metadata")
}
gitDir, err := os.MkdirTemp("", "diple-conflicts-*")
if err != nil {
return nil, fmt.Errorf("create temporary merge repository: %w", err)
}
defer os.RemoveAll(gitDir)
run := func(args ...string) ([]byte, error) {
command := exec.CommandContext(ctx, "git", args...)
command.Env = gitAuthenticationEnvironment(callerEnvironment(), token)
return command.CombinedOutput()
}
if output, runErr := run("init", "--bare", gitDir); runErr != nil {
return nil, commandError("initialize merge analysis", output, runErr)
}
cloneURL := strings.TrimSuffix(strings.TrimSuffix(repositoryURL, "/"), ".git") + ".git"
if output, runErr := run("-C", gitDir, "remote", "add", "origin", cloneURL); runErr != nil {
return nil, commandError("configure merge analysis remote", output, runErr)
}
if output, runErr := run("-C", gitDir, "config", "remote.origin.promisor", "true"); runErr != nil {
return nil, commandError("configure partial clone", output, runErr)
}
if output, runErr := run("-C", gitDir, "config", "remote.origin.partialclonefilter", "blob:none"); runErr != nil {
return nil, commandError("configure partial clone filter", output, runErr)
}
refspecs := []string{
"+refs/heads/" + baseRef + ":refs/diple/base",
"+refs/pull/" + strconv.Itoa(number) + "/head:refs/diple/head",
}
fetch := func(depthArgs ...string) error {
args := []string{"-C", gitDir, "fetch", "--quiet", "--no-tags", "--filter=blob:none"}
args = append(args, depthArgs...)
args = append(args, "origin")
args = append(args, refspecs...)
output, runErr := run(args...)
return commandError("fetch merge inputs", output, runErr)
}
if err := fetch("--depth=64"); err != nil {
return nil, err
}
for _, deepen := range []string{"192", "768"} {
if mergeBaseExists(run, gitDir) {
break
}
if err := fetch("--deepen=" + deepen); err != nil {
return nil, err
}
}
if !mergeBaseExists(run, gitDir) {
if err := fetch("--unshallow"); err != nil {
return nil, err
}
}
output, runErr := run(
"-C", gitDir, "merge-tree", "--write-tree", "--name-only", "--no-messages", "-z",
"refs/diple/base", "refs/diple/head",
)
if runErr == nil {
return nil, nil
}
var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 1 {
return nil, commandError("analyze merge conflicts", output, runErr)
}
return parseConflictFiles(output)
}
func mergeBaseExists(
run func(...string) ([]byte, error),
gitDir string,
) bool {
_, err := run(
"-C", gitDir, "merge-base", "refs/diple/base", "refs/diple/head",
)
return err == nil
}
func parseConflictFiles(output []byte) ([]string, error) {
parts := bytes.Split(output, []byte{0})
if len(parts) < 2 || len(parts[0]) == 0 {
return nil, errors.New("git merge-tree returned malformed conflict data")
}
files := make([]string, 0, len(parts)-2)
seen := make(map[string]bool)
for _, raw := range parts[1:] {
name := string(raw)
if name == "" || seen[name] {
continue
}
seen[name] = true
files = append(files, name)
}
sort.Strings(files)
return files, nil
}
func commandError(action string, output []byte, err error) error {
if err == nil {
return nil
}
message := strings.TrimSpace(string(output))
if message == "" {
return fmt.Errorf("%s: %w", action, err)
}
return fmt.Errorf("%s: %s", action, message)
}
func callerEnvironment() []string {
const prefix = "GIT_CONFIG_"
environment := make([]string, 0, len(os.Environ())+5)
for _, item := range os.Environ() {
if !strings.HasPrefix(item, prefix) && !strings.HasPrefix(item, "GIT_TERMINAL_PROMPT=") {
environment = append(environment, item)
}
}
return environment
}
func gitAuthenticationEnvironment(environment []string, token string) []string {
environment = append(environment, "GIT_TERMINAL_PROMPT=0")
if token == "" {
return environment
}
credentials := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
return append(environment,
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=http.extraHeader",
"GIT_CONFIG_VALUE_0=Authorization: Basic "+credentials,
)
}

86
conflicts_test.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"context"
"encoding/base64"
"errors"
"reflect"
"strings"
"testing"
"time"
)
func TestParseConflictFiles(t *testing.T) {
output := []byte("0123456789abcdef\x00src/a.go\x00docs/name with spaces.md\x00src/a.go\x00")
got, err := parseConflictFiles(output)
if err != nil {
t.Fatal(err)
}
want := []string{"docs/name with spaces.md", "src/a.go"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("conflict files = %#v, want %#v", got, want)
}
}
func TestParseConflictFilesRejectsMalformedOutput(t *testing.T) {
if _, err := parseConflictFiles([]byte("not-delimited")); err == nil {
t.Fatal("malformed merge-tree output was accepted")
}
}
func TestConflictFileCacheUsesCommitPairAndRetriesErrors(t *testing.T) {
client := NewGitHubClient("https://api.github.com/graphql", "secret")
calls := 0
client.conflicts = func(
_ context.Context, _ string, _ int, _ string, _, _, _ string,
) ([]string, error) {
calls++
return []string{"main.go"}, nil
}
first, err := client.loadConflictFiles(
context.Background(), "https://github.com/o/r", 1, "main", "base", "head",
)
if err != nil || len(first) != 1 {
t.Fatalf("first load = %#v, %v", first, err)
}
first[0] = "mutated"
second, err := client.loadConflictFiles(
context.Background(), "https://github.com/o/r", 1, "main", "base", "head",
)
if err != nil || !reflect.DeepEqual(second, []string{"main.go"}) || calls != 1 {
t.Fatalf("cached load = %#v, %v, calls=%d", second, err, calls)
}
failing := NewGitHubClient("https://api.github.com/graphql", "secret")
failedCalls := 0
failing.conflicts = func(
_ context.Context, _ string, _ int, _ string, _, _, _ string,
) ([]string, error) {
failedCalls++
return nil, errors.New("temporary")
}
const repositoryURL = "https://github.com/o/r"
_, _ = failing.loadConflictFiles(
context.Background(), repositoryURL, 1, "main", "base", "head",
)
key := strings.Join([]string{repositoryURL, "base", "head"}, "\x00")
entry := failing.conflictCache[key]
entry.checkedAt = time.Now().Add(-2 * time.Minute)
failing.conflictCache[key] = entry
_, _ = failing.loadConflictFiles(
context.Background(), repositoryURL, 1, "main", "base", "head",
)
if failedCalls != 2 {
t.Fatalf("expired conflict error was not retried; calls=%d", failedCalls)
}
}
func TestGitAuthenticationEnvironmentDoesNotExposeTokenInArguments(t *testing.T) {
got := gitAuthenticationEnvironment([]string{"PATH=/bin"}, "token value")
joined := strings.Join(got, "\n")
credentials := base64.StdEncoding.EncodeToString([]byte("x-access-token:token value"))
if !strings.Contains(joined, "GIT_TERMINAL_PROMPT=0") ||
!strings.Contains(joined, "Authorization: Basic "+credentials) {
t.Fatalf("authentication environment = %#v", got)
}
}

208
drafts.go Normal file
View File

@@ -0,0 +1,208 @@
package main
import (
"encoding/json"
"errors"
"os"
"strconv"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const draftSchemaVersion = 1
type savedDraft struct {
Kind string `json:"kind"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Number int `json:"number"`
ThreadID string `json:"thread_id,omitempty"`
Reply string `json:"reply,omitempty"`
Title string `json:"title,omitempty"`
BaseRef string `json:"base_ref,omitempty"`
Body string `json:"body,omitempty"`
OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
type draftEnvelope struct {
Version int `json:"version"`
Drafts map[string]savedDraft `json:"drafts"`
}
type draftStore struct {
mu sync.Mutex
path string
data map[string]savedDraft
dirty bool
loadErr error
}
func loadDraftStore(path string) *draftStore {
store := &draftStore{path: path, data: make(map[string]savedDraft)}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
store.loadErr = err
}
return store
}
var envelope draftEnvelope
if json.Unmarshal(data, &envelope) == nil && envelope.Version == draftSchemaVersion {
store.data = envelope.Drafts
if store.data == nil {
store.data = make(map[string]savedDraft)
}
} else {
store.loadErr = errors.New("draft file is corrupt or has an unsupported schema version")
}
return store
}
func (s *draftStore) get(key string) (savedDraft, bool) {
if s == nil {
return savedDraft{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
draft, ok := s.data[key]
return draft, ok
}
func (s *draftStore) put(key string, draft savedDraft) {
if s == nil || key == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.data[key]; ok {
existing.SavedAt = time.Time{}
candidate := draft
candidate.SavedAt = time.Time{}
if existing == candidate {
return
}
}
draft.SavedAt = time.Now()
s.data[key] = draft
s.dirty = true
}
func (s *draftStore) delete(key string) error {
if s == nil || key == "" {
return nil
}
s.mu.Lock()
delete(s.data, key)
s.dirty = true
s.mu.Unlock()
return s.flush()
}
func (s *draftStore) flush() error {
if s == nil || s.path == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.dirty {
return nil
}
envelope := draftEnvelope{Version: draftSchemaVersion, Drafts: s.data}
if err := atomicWriteJSON(s.path, envelope, 0o600); err != nil {
return err
}
s.dirty = false
return nil
}
type draftFlushMsg struct{ err error }
func flushDraftsAfter(store *draftStore) tea.Cmd {
if store == nil {
return nil
}
return tea.Tick(400*time.Millisecond, func(time.Time) tea.Msg {
return draftFlushMsg{err: store.flush()}
})
}
func replyDraftKey(owner, repo string, number int, threadID string) string {
return "reply:" + owner + "/" + repo + ":" +
fmtInt(number) + ":" + threadID
}
func prMetadataDraftKey(owner, repo string, number int) string {
return "pr:" + owner + "/" + repo + ":" + fmtInt(number)
}
func fmtInt(value int) string {
return strconv.Itoa(value)
}
func (m *App) restoreReplyDraft(threadID string) {
key := replyDraftKey(m.details.Owner, m.details.Repository, m.details.Number, threadID)
if draft, ok := m.drafts.get(key); ok && draft.Kind == "reply" && draft.Reply != "" {
m.replyDraft = draft.Reply
m.recordHealth(
"draft recovery", healthWarning,
"restored reply draft saved "+draft.SavedAt.Local().Format("2006-01-02 15:04:05"),
)
}
}
func (m *App) queueReplyDraft() tea.Cmd {
if m.drafts == nil || m.writeThreadID == "" {
return nil
}
key := replyDraftKey(
m.details.Owner, m.details.Repository, m.details.Number, m.writeThreadID,
)
m.drafts.put(key, savedDraft{
Kind: "reply", Owner: m.details.Owner, Repository: m.details.Repository,
Number: m.details.Number, ThreadID: m.writeThreadID, Reply: m.replyDraft,
})
return flushDraftsAfter(m.drafts)
}
func (m *App) restorePREditDraft() {
key := prMetadataDraftKey(m.details.Owner, m.details.Repository, m.details.Number)
draft, ok := m.drafts.get(key)
if !ok || draft.Kind != "pr-metadata" {
return
}
if !draft.OriginalUpdatedAt.Equal(m.details.UpdatedAt) {
m.recordHealth(
"draft recovery", healthWarning,
"saved PR metadata draft was not restored because the pull request changed",
)
return
}
m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false)
m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false)
m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(draft.Body), m.editorMode == "vim",
)
m.prEditEditors[prEditBodyField].highlightMarkdown = true
m.recordHealth(
"draft recovery", healthWarning,
"restored PR metadata draft saved "+draft.SavedAt.Local().Format("2006-01-02 15:04:05"),
)
}
func (m *App) queuePREditDraft() tea.Cmd {
if m.drafts == nil {
return nil
}
key := prMetadataDraftKey(m.details.Owner, m.details.Repository, m.details.Number)
m.drafts.put(key, savedDraft{
Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository,
Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text,
BaseRef: m.prEditEditors[prEditBaseField].Text,
Body: m.prEditEditors[prEditBodyField].Text,
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
})
return flushDraftsAfter(m.drafts)
}

1598
github.go

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,95 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestGraphQLRequestRecordsRateLimitHeaders(t *testing.T) {
reset := time.Now().Add(time.Hour).Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Limit", "5000")
w.Header().Set("X-RateLimit-Remaining", "321")
w.Header().Set("X-RateLimit-Used", "4679")
w.Header().Set("X-RateLimit-Reset", fmtInt64(reset))
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"octocat"}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
var target struct {
Viewer struct{ Login string }
}
if err := client.query(context.Background(), "query { viewer { login } }", nil, &target); err != nil {
t.Fatal(err)
}
rate := client.RateLimit()
if rate.Limit != 5000 || rate.Remaining != 321 || rate.Used != 4679 ||
rate.ResetAt.Unix() != reset || rate.UpdatedAt.IsZero() {
t.Fatalf("rate limit = %#v", rate)
}
}
func fmtInt64(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestListBranchesPaginatesAndMarksTheDefaultBranch(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if !strings.Contains(request.Query, "query RepositoryBranches") {
t.Fatalf("unexpected query: %s", request.Query)
}
if requests == 1 {
if request.Variables["after"] != nil {
t.Fatalf("first cursor = %#v", request.Variables["after"])
}
_, _ = w.Write([]byte(`{"data":{"repository":{
"defaultBranchRef":{"name":"main"},
"refs":{"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[
{"name":"feature/old","target":{"committedDate":"2024-01-01T00:00:00Z"}},
{"name":"main","target":{"committedDate":"2025-01-01T00:00:00Z"}}
]}
}}}`))
return
}
if request.Variables["after"] != "next" {
t.Fatalf("second cursor = %#v", request.Variables["after"])
}
_, _ = w.Write([]byte(`{"data":{"repository":{
"defaultBranchRef":{"name":"main"},
"refs":{"pageInfo":{"hasNextPage":false},"nodes":[
{"name":"release/2.0","target":{"committedDate":"2026-07-28T00:00:00Z"}}
]}
}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
branches, err := client.ListBranches(context.Background(), "o", "r")
if err != nil {
t.Fatal(err)
}
if requests != 2 || len(branches) != 3 {
t.Fatalf("requests=%d branches=%#v", requests, branches)
}
if branches[0].Name != "main" || !branches[0].IsDefault {
t.Fatalf("default branch was not first and marked: %#v", branches)
}
wantUpdated := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC)
if branches[1].Name != "release/2.0" || !branches[1].UpdatedAt.Equal(wantUpdated) {
t.Fatalf("fresh branch was not decoded and sorted: %#v", branches)
}
}
func TestListPullRequestsSearchesAssignedPRsInRepository(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" {
@@ -73,6 +158,40 @@ func TestListPullRequestsRejectsGlobalShowAll(t *testing.T) {
} }
} }
func TestListPullRequestsPaginatesToConfiguredLimit(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
cursor := request.Variables["after"]
if requests == 1 {
if cursor != nil || request.Variables["first"] != float64(100) {
t.Fatalf("first page variables = %#v", request.Variables)
}
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[]}}}`))
return
}
if cursor != "next" || request.Variables["first"] != float64(50) {
t.Fatalf("second page variables = %#v", request.Variables)
}
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
"pageInfo":{"hasNextPage":false},"nodes":[]}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
if _, err := client.ListPullRequests(context.Background(), "", "", 150, false); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}
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"}]}`))
@@ -85,25 +204,333 @@ func TestGraphQLErrorsAreReturned(t *testing.T) {
} }
} }
func TestThreadWriteMutationsUseThreadIDs(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
input, _ := request.Variables["input"].(map[string]any)
switch {
case strings.Contains(request.Query, "unresolveReviewThread"):
if input["threadId"] != "thread" {
t.Fatalf("unresolve input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"unresolveReviewThread":{"thread":{
"id":"thread","path":"a.go","isResolved":false,"viewerCanResolve":true
}}}}`))
case strings.Contains(request.Query, "resolveReviewThread"):
if input["threadId"] != "thread" {
t.Fatalf("resolve input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{
"id":"thread","path":"a.go","isResolved":true,"viewerCanUnresolve":true
}}}}`))
case strings.Contains(request.Query, "addPullRequestReviewThreadReply"):
if input["pullRequestReviewThreadId"] != "thread" || input["body"] != "reply body" {
t.Fatalf("reply input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"addPullRequestReviewThreadReply":{"comment":{
"id":"comment","body":"reply body","createdAt":"2026-01-01T00:00:00Z",
"url":"https://example/comment","author":{"login":"me"}
}}}}`))
default:
t.Fatalf("unexpected mutation: %s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
resolved, err := client.SetThreadResolved(context.Background(), "thread", true)
if err != nil || !resolved.IsResolved || !resolved.ViewerCanUnresolve {
t.Fatalf("resolve result = %#v, error = %v", resolved, err)
}
unresolved, err := client.SetThreadResolved(context.Background(), "thread", false)
if err != nil || unresolved.IsResolved || !unresolved.ViewerCanResolve {
t.Fatalf("unresolve result = %#v, error = %v", unresolved, err)
}
comment, err := client.ReplyToThread(context.Background(), "thread", "reply body")
if err != nil || comment.ID != "comment" || comment.Author != "me" || comment.Body != "reply body" {
t.Fatalf("reply result = %#v, error = %v", comment, err)
}
if requests != 3 {
t.Fatalf("mutation requests = %d, want 3", requests)
}
}
func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(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)
}
if !strings.Contains(request.Query, "mutation UpdatePullRequest") {
t.Fatalf("unexpected mutation:\n%s", request.Query)
}
input := request.Variables["input"].(map[string]any)
if input["pullRequestId"] != "pr-id" || input["title"] != "New title" ||
input["body"] != "- [x] done" || input["baseRefName"] != "release" {
t.Fatalf("update input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"updatePullRequest":{"pullRequest":{
"id":"pr-id","title":"New title","body":"- [x] done","baseRefName":"release",
"updatedAt":"2026-07-28T12:00:00Z","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN"
}}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
got, err := client.UpdatePullRequest(context.Background(), "pr-id", PullRequestMetadata{
Title: "New title", Body: "- [x] done", BaseRef: "release",
})
if err != nil {
t.Fatal(err)
}
if got.Title != "New title" || got.Body != "- [x] done" || got.BaseRef != "release" ||
got.Mergeable != "UNKNOWN" || got.UpdatedAt.IsZero() {
t.Fatalf("updated pull request = %#v", got)
}
}
func TestMergeAndAutoMergeMutationsUseExpectedHeadOID(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
input := request.Variables["input"].(map[string]any)
if input["pullRequestId"] != "pr-id" {
t.Fatalf("mutation input = %#v", input)
}
switch {
case strings.Contains(request.Query, "EnablePullRequestAutoMerge"):
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
t.Fatalf("enable input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"enablePullRequestAutoMerge":{"pullRequest":{
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-07-28T12:00:00Z",
"enabledBy":{"login":"me"}}
}}}}`))
case strings.Contains(request.Query, "DisablePullRequestAutoMerge"):
_, _ = w.Write([]byte(`{"data":{"disablePullRequestAutoMerge":{
"pullRequest":{"id":"pr-id","autoMergeRequest":null}
}}}`))
case strings.Contains(request.Query, "MergePullRequest"):
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
t.Fatalf("merge input = %#v", input)
}
_, _ = w.Write([]byte(`{"data":{"mergePullRequest":{"pullRequest":{
"merged":true,"mergedAt":"2026-07-28T12:01:00Z"
}}}}`))
default:
t.Fatalf("unexpected mutation:\n%s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
auto, err := client.SetPullRequestAutoMerge(
context.Background(), "pr-id", "head", "SQUASH", true,
)
if err != nil || auto == nil || auto.MergeMethod != "SQUASH" || auto.EnabledBy != "me" {
t.Fatalf("enable result = %#v, error = %v", auto, err)
}
if _, err := client.SetPullRequestAutoMerge(
context.Background(), "pr-id", "head", "SQUASH", false,
); err != nil {
t.Fatal(err)
}
merged, err := client.MergePullRequest(
context.Background(), "pr-id", "head", "SQUASH",
)
if err != nil || !merged.Merged || merged.MergedAt.IsZero() {
t.Fatalf("merge result = %#v, error = %v", merged, err)
}
if requests != 3 {
t.Fatalf("mutation requests = %d, want 3", requests)
}
}
func TestCheckContextsAndAnnotationsArePaginated(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)
}
switch {
case strings.Contains(request.Query, "query CheckContextsPage"):
_, _ = w.Write([]byte(`{"data":{"node":{"contexts":{
"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"check-2","name":"lint","conclusion":"FAILURE"
}]}}}}`))
case strings.Contains(request.Query, "query CheckAnnotationsPage"):
if request.Variables["after"] == nil {
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
"pageInfo":{"hasNextPage":true,"endCursor":"annotation-next"},
"nodes":[{"path":"a.go","location":{"start":{"line":4},"end":{"line":4}},
"annotationLevel":"FAILURE","message":"first"}]
}}}}`))
} else {
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
"pageInfo":{"hasNextPage":false},
"nodes":[{"path":"b.go","location":{"start":{"line":8},"end":{"line":8}},
"annotationLevel":"WARNING","message":"second"}]
}}}}`))
}
default:
t.Fatalf("unexpected query: %s", request.Query)
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
nodes, err := client.allCheckContexts(context.Background(), githubCheckContextConnection{
PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "context-next"},
Nodes: []githubCheckContext{{ID: "check-1", Name: "tests", Conclusion: "SUCCESS"}},
}, "rollup")
if err != nil {
t.Fatal(err)
}
annotations, err := client.checkAnnotations(context.Background(), nodes[1].ID)
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || len(annotations) != 2 ||
annotations[0].Location.Start.Line != 4 ||
annotations[1].Location.End.Line != 8 {
t.Fatalf("paginated checks = %#v", nodes)
}
}
func TestCheckQueriesUseCurrentGitHubSchemaShape(t *testing.T) {
for name, query := range map[string]string{"annotations": checkAnnotationsPageQuery} {
if strings.Contains(query, "output {") ||
strings.Contains(query, "nodes { path startLine endLine annotationLevel") ||
!strings.Contains(query, "location { start { line column } end { line column } }") {
t.Fatalf("%s query uses obsolete CheckRun/CheckAnnotation fields:\n%s", name, query)
}
}
for name, query := range map[string]string{
"details": detailsQuery, "context page": checkContextsPageQuery,
} {
if strings.Contains(query, "annotations(first:") {
t.Fatalf("%s query eagerly loads annotations:\n%s", name, query)
}
}
}
func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(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)
}
switch {
case strings.Contains(request.Query, "query ReviewThreadsPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviewThreads":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"thread-2","path":"b.go","line":20,"diffSide":"RIGHT",
"isResolved":false,"isOutdated":false,"viewerCanResolve":true,
"comments":{"pageInfo":{"hasNextPage":true,"endCursor":"comment-cursor"},
"nodes":[{"id":"review-comment-2a","body":"first","createdAt":"2026-01-02T00:00:00Z"}]}}]
}}}}}`))
case strings.Contains(request.Query, "query ReviewCommentsPage"):
_, _ = w.Write([]byte(`{"data":{"node":{"comments":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"review-comment-2b","body":"second","createdAt":"2026-01-03T00:00:00Z"}]
}}}}`))
case strings.Contains(request.Query, "query ConversationPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"comments":{
"totalCount":2,"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"conversation-2","body":"reply","createdAt":"2026-01-03T00:00:00Z"}]
}}}}}`))
case strings.Contains(request.Query, "query ReviewsPage"):
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviews":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
}}}}}`))
default:
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
"id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
"latestReviews":{"nodes":[]},"commits":{"totalCount":1,"nodes":[]},
"comments":{"totalCount":2,"pageInfo":{"hasNextPage":true,"endCursor":"conversation-cursor"},
"nodes":[{"id":"conversation-1","body":"start","createdAt":"2026-01-01T00:00:00Z"}]},
"reviews":{"pageInfo":{"hasNextPage":true,"endCursor":"review-cursor"},
"nodes":[{"id":"review-1","body":"changes","state":"CHANGES_REQUESTED","submittedAt":"2026-01-02T00:00:00Z"}]},
"reviewThreads":{"pageInfo":{"hasNextPage":true,"endCursor":"thread-cursor"},
"nodes":[{"id":"thread-1","path":"a.go","line":10,"diffSide":"RIGHT",
"isResolved":false,"isOutdated":false,
"comments":{"pageInfo":{"hasNextPage":false},
"nodes":[{"id":"review-comment-1","body":"fix","createdAt":"2026-01-01T00:00:00Z"}]}}]}
}}}}`))
}
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
got, err := client.GetPullRequest(context.Background(), "o", "r", 1)
if err != nil {
t.Fatal(err)
}
if len(got.Threads) != 2 || len(got.Threads[1].Comments) != 2 ||
len(got.Conversation) != 2 || len(got.Reviews) != 2 {
t.Fatalf("paginated details were incomplete: %#v", got)
}
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
t.Fatalf("permissions = %#v", got.Permissions)
}
}
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { func TestGetPullRequestUsesOriginalLineAndMetadata(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(`{"data":{"repository":{"pullRequest":{ _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE",
"squashMergeAllowed":true,"mergeCommitAllowed":false,"rebaseMergeAllowed":true,
"pullRequest":{
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false, "id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
"updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED", "createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"}, "mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
"additions":12,"deletions":4,"changedFiles":3,
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
"viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true,
"viewerCanEnableAutoMerge":false,"viewerCanDisableAutoMerge":true,
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-01-01T01:00:00Z",
"enabledBy":{"login":"zam"}},
"baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true,
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
"author":{"login":"zam"},
"assignees":{"nodes":[{"login":"sam"}]}, "assignees":{"nodes":[{"login":"sam"}]},
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
"milestone":{"title":"v2"},
"comments":{"totalCount":5},
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]}, "reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]}, "latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
"commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}, "commits":{"totalCount":7,"nodes":[{"commit":{"oid":"abcdef0123456789",
"statusCheckRollup":{"state":"FAILURE","contexts":{"nodes":[
{"name":"tests","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"https://checks/tests"},
{"context":"legacy","state":"SUCCESS","targetUrl":"https://checks/legacy"}
]}}}}]},
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{ "reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go", "id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
"viewerCanResolve":true,
"line":null,"originalLine":42,"diffSide":"RIGHT", "line":null,"originalLine":42,"diffSide":"RIGHT",
"startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT", "startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT",
"comments":{"pageInfo":{"hasNextPage":true},"nodes":[{ "comments":{"pageInfo":{"hasNextPage":false},"nodes":[{
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z", "id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
"url":"cu","author":{"login":"reviewer"},"outdated":true, "url":"cu","author":{"login":"reviewer"},"outdated":true,
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40, "line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
"originalCommit":{"oid":"0123456789abcdef"} "originalCommit":{"oid":"0123456789abcdef"},
"reactionGroups":[
{"content":"THUMBS_UP","viewerHasReacted":true,"reactors":{"totalCount":3}},
{"content":"EYES","viewerHasReacted":false,"reactors":{"totalCount":1}},
{"content":"HEART","viewerHasReacted":false,"reactors":{"totalCount":0}}
]
}]} }]}
}]} }]}
}}}}`)) }}}}`))
@@ -118,8 +545,26 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" { if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" {
t.Fatalf("unexpected metadata: %#v", got) t.Fatalf("unexpected metadata: %#v", got)
} }
if got.MergeState != "CLEAN" || got.Additions != 12 || got.Deletions != 4 ||
got.ChangedFiles != 3 || got.CommitCount != 7 || got.CommentCount != 5 ||
got.Milestone != "v2" || strings.Join(got.Labels, ",") != "bug,backend" ||
got.CreatedAt.IsZero() {
t.Fatalf("unexpected dashboard metadata: %#v", got)
}
if got.HeadOID != "abcdef0123456789" || len(got.Checks) != 2 ||
got.Checks[0].Name != "tests" || got.Checks[1].Name != "legacy" ||
got.Permissions.Repository != "WRITE" || !got.Permissions.CanUpdatePR ||
!got.Permissions.CanResolveAny || got.Requirements.ApprovalsRequired != 2 ||
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
t.Fatalf("unexpected read capabilities: %#v", got)
}
if got.AutoMerge == nil || got.AutoMerge.MergeMethod != "SQUASH" ||
got.AutoMerge.EnabledBy != "zam" || !got.Permissions.CanDisableMerge ||
strings.Join(got.AllowedMergeMethods, ",") != "SQUASH,REBASE" {
t.Fatalf("unexpected merge metadata: %#v", got)
}
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 || if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated { got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
t.Fatalf("unexpected thread: %#v", got.Threads) t.Fatalf("unexpected thread: %#v", got.Threads)
} }
comment := got.Threads[0].Comments[0] comment := got.Threads[0].Comments[0]
@@ -127,4 +572,56 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated { comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
t.Fatalf("unexpected comment snapshot: %#v", comment) t.Fatalf("unexpected comment snapshot: %#v", comment)
} }
if len(comment.Reactions) != 2 ||
comment.Reactions[0] != (ReactionSummary{Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}) ||
comment.Reactions[1] != (ReactionSummary{Content: "EYES", Count: 1}) {
t.Fatalf("unexpected comment reactions: %#v", comment.Reactions)
}
}
func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"repository":{
"url":"https://github.com/o/r","pullRequest":{
"id":"pr","number":3,"title":"Conflict","url":"u",
"mergeable":"CONFLICTING","baseRefName":"main","headRefName":"feature",
"headRefOid":"head123","baseRef":{"target":{"oid":"base123"}},
"comments":{"pageInfo":{"hasNextPage":false}},
"reviews":{"pageInfo":{"hasNextPage":false}},
"timelineItems":{"pageInfo":{"hasNextPage":false}},
"reviewThreads":{"pageInfo":{"hasNextPage":false}}
}
}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
client.conflicts = func(
_ context.Context,
repositoryURL string,
number int,
baseRef, baseOID, headOID, token string,
) ([]string, error) {
if repositoryURL != "https://github.com/o/r" || number != 3 ||
baseRef != "main" || baseOID != "base123" || headOID != "head123" ||
token != "secret" {
t.Fatalf(
"conflict loader arguments = %q, %d, %q, %q, %q, %q",
repositoryURL, number, baseRef, baseOID, headOID, token,
)
}
return []string{"src/conflict.go", "README.md"}, nil
}
got, err := client.GetPullRequest(context.Background(), "o", "r", 3)
if err != nil {
t.Fatal(err)
}
if len(got.ConflictFiles) != 0 {
t.Fatalf("core refresh loaded conflict files eagerly: %#v", got.ConflictFiles)
}
enrichment := client.EnrichPullRequest(context.Background(), got)
if !reflect.DeepEqual(enrichment.ConflictFiles, []string{"src/conflict.go", "README.md"}) {
t.Fatalf("conflict files = %#v", enrichment.ConflictFiles)
}
} }

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.pablu.de/Pablu/gh-threads module git.pablu.de/Pablu/diple
go 1.24.0 go 1.24.0

157
health.go Normal file
View File

@@ -0,0 +1,157 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
type requestCoordinator struct {
mu sync.Mutex
id uint64
cancel context.CancelFunc
}
func (r *requestCoordinator) start(timeout time.Duration) (context.Context, context.CancelFunc, uint64) {
r.mu.Lock()
defer r.mu.Unlock()
if r.cancel != nil {
r.cancel()
}
r.id++
ctx, cancel := context.WithTimeout(context.Background(), timeout)
r.cancel = cancel
return ctx, cancel, r.id
}
func (r *requestCoordinator) current(id uint64) bool {
if id == 0 {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
return r.id == id
}
type HealthLevel string
const (
healthOK HealthLevel = "ok"
healthInfo HealthLevel = "info"
healthWarning HealthLevel = "warning"
healthError HealthLevel = "error"
healthUnknown HealthLevel = "unknown"
)
type HealthComponent struct {
Name string
Level HealthLevel
Summary string
Detail string
UpdatedAt time.Time
}
type HealthEvent struct {
Component string
Level HealthLevel
Message string
At time.Time
}
type RateLimitSnapshot struct {
Limit int
Remaining int
Used int
ResetAt time.Time
RetryAfter time.Time
UpdatedAt time.Time
}
type healthProvider interface {
HealthReport() []HealthComponent
RateLimit() RateLimitSnapshot
}
type healthTracker struct {
mu sync.Mutex
components map[string]HealthComponent
rate RateLimitSnapshot
}
func (h *healthTracker) set(component HealthComponent) {
h.mu.Lock()
defer h.mu.Unlock()
if h.components == nil {
h.components = make(map[string]HealthComponent)
}
if component.UpdatedAt.IsZero() {
component.UpdatedAt = time.Now()
}
h.components[component.Name] = component
}
func (h *healthTracker) setRate(rate RateLimitSnapshot) {
h.mu.Lock()
defer h.mu.Unlock()
h.rate = rate
}
func (h *healthTracker) report() []HealthComponent {
h.mu.Lock()
defer h.mu.Unlock()
components := make([]HealthComponent, 0, len(h.components))
for _, component := range h.components {
components = append(components, component)
}
sort.Slice(components, func(i, j int) bool {
return strings.ToLower(components[i].Name) < strings.ToLower(components[j].Name)
})
return components
}
func (h *healthTracker) rateLimit() RateLimitSnapshot {
h.mu.Lock()
defer h.mu.Unlock()
return h.rate
}
func (m *App) recordHealth(component string, level HealthLevel, message string) {
if strings.TrimSpace(message) == "" {
return
}
event := HealthEvent{Component: component, Level: level, Message: message, At: time.Now()}
const maximumHealthEvents = 100
m.healthEvents = append(m.healthEvents, event)
if len(m.healthEvents) > maximumHealthEvents {
m.healthEvents = append([]HealthEvent(nil), m.healthEvents[len(m.healthEvents)-maximumHealthEvents:]...)
}
}
func healthLevelLabel(level HealthLevel) string {
switch level {
case healthOK:
return "OK"
case healthInfo:
return "INFO"
case healthWarning:
return "WARN"
case healthError:
return "ERROR"
default:
return "UNKNOWN"
}
}
func healthComponentText(component HealthComponent) string {
text := component.Summary
if component.Detail != "" {
text += " — " + component.Detail
}
if !component.UpdatedAt.IsZero() {
text += " (" + component.UpdatedAt.Local().Format("15:04:05") + ")"
}
return fmt.Sprintf("%-7s %-20s %s", healthLevelLabel(component.Level), component.Name, text)
}

201
health_test.go Normal file
View File

@@ -0,0 +1,201 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
type healthTestService struct {
components []HealthComponent
rate RateLimitSnapshot
}
func (s *healthTestService) ListPullRequests(
context.Context, string, string, int, bool,
) ([]PullRequest, error) {
return nil, nil
}
func (s *healthTestService) GetPullRequest(
context.Context, string, string, int,
) (PRDetails, error) {
return PRDetails{}, nil
}
func (s *healthTestService) HealthReport() []HealthComponent {
return append([]HealthComponent(nil), s.components...)
}
func (s *healthTestService) RateLimit() RateLimitSnapshot { return s.rate }
func TestHealthScreenReportsComponentsRateLimitAndEvents(t *testing.T) {
service := &healthTestService{
components: []HealthComponent{{
Name: "GitHub API", Level: healthOK, Summary: "request succeeded",
}},
rate: RateLimitSnapshot{
Limit: 5000, Remaining: 42, UpdatedAt: time.Now(),
ResetAt: time.Now().Add(time.Hour),
},
}
settings := defaultAppSettings()
settings.ReadState = loadReadState(t.TempDir() + "/state.json")
settings.Drafts = loadDraftStore(t.TempDir() + "/drafts.json")
app := NewAppWithSettings(service, "o", "r", false, 50, time.Minute, settings)
app.width, app.height = 64, 30
app.details = PRDetails{
PullRequest: PullRequest{ID: "pr", UpdatedAt: time.Now()},
DataIssues: []DataIssue{{Component: "timeline", Message: "unavailable"}},
}
app.recordHealth("timeline", healthWarning, "a deliberately long warning that must remain readable")
lines := app.healthLines()
plain := ansi.Strip(strings.Join(lines, "\n"))
for _, wanted := range []string{
"configuration", "read state", "draft persistence", "GitHub API",
"rate limit", "42/5000", "PR core data", "timeline",
} {
if !strings.Contains(plain, wanted) {
t.Fatalf("health output missing %q:\n%s", wanted, plain)
}
}
for _, line := range lines {
if ansi.StringWidth(line) > app.width-2 {
t.Fatalf("health line width = %d, want <= %d: %q", ansi.StringWidth(line), app.width-2, line)
}
}
}
func TestHealthScreenOpensAndReturnsToPreviousScreen(t *testing.T) {
app := NewApp(nil, "", "", false, 50, time.Minute)
app.screen = dashboardScreen
app.scroll = 7
app.width, app.height = 100, 30
updated, _ := app.Update(runeKey("H"))
app = updated.(App)
if app.screen != healthScreen || app.healthReturn != dashboardScreen || app.scroll != 7 {
t.Fatalf("health navigation = screen %v return %v", app.screen, app.healthReturn)
}
view := ansi.Strip(app.View())
if !strings.Contains(view, "Application health") ||
!strings.Contains(view, "╭") || !strings.Contains(view, "╰") {
t.Fatalf("health is not rendered as a modal:\n%s", view)
}
updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyEsc})
app = updated.(App)
if app.screen != dashboardScreen || app.scroll != 7 {
t.Fatalf("health back returned to screen %v at scroll %d", app.screen, app.scroll)
}
}
func TestHealthRefreshLineIsStableAndInformational(t *testing.T) {
app := NewApp(nil, "", "", false, 50, time.Minute)
app.width, app.height = 100, 30
app.loading = false
idle := app.healthLines()
app.loading = true
loading := app.healthLines()
findRefresh := func(lines []string) (int, string) {
for index, line := range lines {
plain := ansi.Strip(line)
if strings.Contains(plain, "refresh") {
return index, plain
}
}
return -1, ""
}
idleIndex, idleLine := findRefresh(idle)
loadingIndex, loadingLine := findRefresh(loading)
if idleIndex < 0 || idleIndex != loadingIndex {
t.Fatalf("refresh line moved from %d to %d", idleIndex, loadingIndex)
}
if !strings.Contains(idleLine, "OK") || !strings.Contains(loadingLine, "INFO") ||
strings.Contains(loadingLine, "WARN") {
t.Fatalf("refresh states: idle=%q loading=%q", idleLine, loadingLine)
}
}
func TestAdaptivePollingHonorsRateLimitBackoff(t *testing.T) {
now := time.Unix(1000, 0)
service := &healthTestService{rate: RateLimitSnapshot{
Limit: 5000, Remaining: 100, UpdatedAt: now,
}}
app := NewApp(service, "", "", false, 50, 10*time.Second)
got := app.adaptivePollInterval(now)
if got < 72*time.Second || got > 88*time.Second {
t.Fatalf("low-budget interval = %s, want about 80s with jitter", got)
}
service.rate.RetryAfter = now.Add(2 * time.Minute)
got = app.adaptivePollInterval(now)
if got < 108*time.Second || got > 132*time.Second {
t.Fatalf("retry-after interval = %s, want about 2m with jitter", got)
}
}
func TestRequestCoordinatorCancelsSupersededRequest(t *testing.T) {
var coordinator requestCoordinator
first, cancelFirst, firstID := coordinator.start(time.Minute)
defer cancelFirst()
_, cancelSecond, secondID := coordinator.start(time.Minute)
defer cancelSecond()
select {
case <-first.Done():
default:
t.Fatal("superseded request context was not canceled")
}
if coordinator.current(firstID) || !coordinator.current(secondID) {
t.Fatalf("current request ids: first=%t second=%t",
coordinator.current(firstID), coordinator.current(secondID))
}
if !errors.Is(first.Err(), context.Canceled) {
t.Fatalf("first context error = %v", first.Err())
}
}
func TestPartialRefreshPreservesLastCompleteSubsections(t *testing.T) {
previous := PRDetails{
PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1},
HeadOID: "head",
BaseOID: "base",
Threads: []ReviewThread{{ID: "old-thread"}, {ID: "second-thread"}},
Conversation: []PRComment{{ID: "old-comment"}, {ID: "second-comment"}},
ConflictFiles: []string{"conflicted.go"},
Checks: []Check{{
ID: "check", Annotations: []CheckAnnotation{{Path: "problem.go"}},
}},
}
fresh := PRDetails{
PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1},
HeadOID: "head",
BaseOID: "base",
Threads: []ReviewThread{{ID: "first-page-only"}},
Conversation: []PRComment{{ID: "first-page-only"}},
Checks: []Check{{ID: "check"}},
DataIssues: []DataIssue{
{Component: "review threads", Message: "page failed"},
{Component: "conversation", Message: "page failed"},
},
}
merged := preservePartialPRData(fresh, previous)
if len(merged.Threads) != 2 || merged.Threads[0].ID != "old-thread" {
t.Fatalf("preserved threads = %#v", merged.Threads)
}
if len(merged.Conversation) != 2 || merged.Conversation[0].ID != "old-comment" {
t.Fatalf("preserved conversation = %#v", merged.Conversation)
}
if len(merged.DataIssues) != 2 {
t.Fatalf("partial markers were lost: %#v", merged.DataIssues)
}
if len(merged.ConflictFiles) != 1 || len(merged.Checks[0].Annotations) != 1 {
t.Fatalf("secondary data flickered during core refresh: %#v", merged)
}
}

View File

@@ -14,6 +14,7 @@ import (
const reviewContextLines = 3 const reviewContextLines = 3
var codeHighlightTheme = "github-dark" var codeHighlightTheme = "github-dark"
var colorEnabled = true
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
@@ -53,15 +54,27 @@ func highlightDiff(path, hunk string, startLine, endLine int, side string) []hig
out := make([]highlightedDiffLine, 0, len(visible)) out := make([]highlightedDiffLine, 0, len(visible))
for _, line := range visible { for _, line := range visible {
if line.raw == "⋯" { if line.raw == "⋯" {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m⋯\x1b[0m"}) code := "⋯"
if colorEnabled {
code = "\x1b[38;5;245m⋯\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue continue
} }
if line.notice { if line.notice {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m" + line.raw + "\x1b[0m"}) code := line.raw
if colorEnabled {
code = "\x1b[38;5;245m" + line.raw + "\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue continue
} }
if line.header { if line.header {
out = append(out, highlightedDiffLine{code: "\x1b[38;5;141m" + line.raw + "\x1b[0m"}) code := line.raw
if colorEnabled {
code = "\x1b[38;5;141m" + line.raw + "\x1b[0m"
}
out = append(out, highlightedDiffLine{code: code})
continue continue
} }
out = append(out, renderDiffLine(lexer, line, padding)) out = append(out, renderDiffLine(lexer, line, padding))
@@ -176,11 +189,15 @@ func renderDiffLine(lexer string, line parsedDiffLine, padding int) highlightedD
source = strings.ReplaceAll(source, "\t", " ") source = strings.ReplaceAll(source, "\t", " ")
source = trimIndent(source, padding) source = trimIndent(source, padding)
return highlightedDiffLine{ gutter := fmt.Sprintf("%5s %s ", lineNumber, marker)
gutter: fmt.Sprintf( if colorEnabled {
gutter = fmt.Sprintf(
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ", "\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
lineNumber, markerStyle, marker, lineNumber, markerStyle, marker,
), )
}
return highlightedDiffLine{
gutter: gutter,
code: highlightedSource(lexer, source), code: highlightedSource(lexer, source),
selected: line.selected, selected: line.selected,
} }
@@ -224,6 +241,9 @@ func coordinateText(line int) string {
} }
func highlightedSource(lexer, source string) string { func highlightedSource(lexer, source string) string {
if !colorEnabled {
return source
}
var highlighted bytes.Buffer var highlighted bytes.Buffer
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil { if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
return source return source

698
keybindings.go Normal file
View File

@@ -0,0 +1,698 @@
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
type KeyBindings struct {
General GeneralKeyBindings `toml:"general"`
Navigation NavigationKeyBindings `toml:"navigation"`
Views ViewKeyBindings `toml:"views"`
Threads ThreadKeyBindings `toml:"threads"`
Input InputKeyBindings `toml:"input"`
Vim VimKeyBindings `toml:"vim"`
}
type GeneralKeyBindings struct {
Quit []string `toml:"quit"`
Help []string `toml:"help"`
Refresh []string `toml:"refresh"`
Back []string `toml:"back"`
Confirm []string `toml:"confirm"`
Reject []string `toml:"reject"`
}
type NavigationKeyBindings struct {
Down []string `toml:"down"`
Up []string `toml:"up"`
Left []string `toml:"left"`
Right []string `toml:"right"`
First []string `toml:"first"`
Last []string `toml:"last"`
PageDown []string `toml:"page_down"`
PageUp []string `toml:"page_up"`
}
type ViewKeyBindings struct {
Open []string `toml:"open"`
Dashboard []string `toml:"dashboard"`
Health []string `toml:"health"`
Edit []string `toml:"edit"`
AutoMerge []string `toml:"auto_merge"`
MergeNow []string `toml:"merge_now"`
ToggleList []string `toml:"toggle_list"`
}
type ThreadKeyBindings struct {
Search []string `toml:"search"`
ClearFilter []string `toml:"clear_filter"`
NextUnread []string `toml:"next_unread"`
PreviousUnread []string `toml:"previous_unread"`
Reply []string `toml:"reply"`
Resolve []string `toml:"resolve"`
Toggle []string `toml:"toggle"`
FoldPrefix []string `toml:"fold_prefix"`
FoldToggle []string `toml:"fold_toggle"`
}
type InputKeyBindings struct {
Cancel []string `toml:"cancel"`
Submit []string `toml:"submit"`
Newline []string `toml:"newline"`
DeleteBackward []string `toml:"delete_backward"`
DeleteForward []string `toml:"delete_forward"`
Clear []string `toml:"clear"`
NextField []string `toml:"next_field"`
PreviousField []string `toml:"previous_field"`
NextCompletion []string `toml:"next_completion"`
PreviousCompletion []string `toml:"previous_completion"`
LineStart []string `toml:"line_start"`
LineEnd []string `toml:"line_end"`
}
type VimKeyBindings struct {
Insert []string `toml:"insert"`
Append []string `toml:"append"`
InsertLineStart []string `toml:"insert_line_start"`
AppendLineEnd []string `toml:"append_line_end"`
OpenBelow []string `toml:"open_below"`
OpenAbove []string `toml:"open_above"`
ReplaceCharacter []string `toml:"replace_character"`
Visual []string `toml:"visual"`
VisualLine []string `toml:"visual_line"`
SelectionOtherEnd []string `toml:"selection_other_end"`
Yank []string `toml:"yank"`
Delete []string `toml:"delete"`
DeleteBefore []string `toml:"delete_before"`
Paste []string `toml:"paste"`
LineStart []string `toml:"line_start"`
FirstNonBlank []string `toml:"first_non_blank"`
LineEnd []string `toml:"line_end"`
WordForward []string `toml:"word_forward"`
WORDForward []string `toml:"big_word_forward"`
WordBackward []string `toml:"word_backward"`
WORDBackward []string `toml:"big_word_backward"`
WordEnd []string `toml:"word_end"`
WORDEnd []string `toml:"big_word_end"`
GoPrefix []string `toml:"go_prefix"`
FindForward []string `toml:"find_forward"`
FindBackward []string `toml:"find_backward"`
TillForward []string `toml:"till_forward"`
TillBackward []string `toml:"till_backward"`
RepeatFind []string `toml:"repeat_find"`
RepeatFindReverse []string `toml:"repeat_find_reverse"`
}
func defaultKeyBindings() KeyBindings {
return KeyBindings{
General: GeneralKeyBindings{
Quit: []string{"q", "ctrl+c"}, Help: []string{"?", "f1"},
Refresh: []string{"r"}, Back: []string{"b", "esc"},
Confirm: []string{"y"}, Reject: []string{"n", "esc"},
},
Navigation: NavigationKeyBindings{
Down: []string{"j", "down"}, Up: []string{"k", "up"},
Left: []string{"h", "left"}, Right: []string{"l", "right"},
First: []string{"g"}, Last: []string{"G"},
PageDown: []string{"ctrl+d", "pgdown"}, PageUp: []string{"ctrl+u", "pgup"},
},
Views: ViewKeyBindings{
Open: []string{"enter", "l"}, Dashboard: []string{"d"},
Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"},
AutoMerge: []string{"a"}, MergeNow: []string{"M"},
},
Threads: ThreadKeyBindings{
Search: []string{"/"}, ClearFilter: []string{"F"},
NextUnread: []string{"n"}, PreviousUnread: []string{"N"},
Reply: []string{"c"}, Resolve: []string{"R"}, Toggle: []string{"enter"},
FoldPrefix: []string{"z"}, FoldToggle: []string{"a"},
},
Input: InputKeyBindings{
Cancel: []string{"esc"}, Submit: []string{"ctrl+s"}, Newline: []string{"enter"},
DeleteBackward: []string{"backspace"}, DeleteForward: []string{"delete"},
Clear: []string{"ctrl+u"}, NextField: []string{"tab"},
PreviousField: []string{"shift+tab"}, NextCompletion: []string{"ctrl+n"},
PreviousCompletion: []string{"ctrl+p"},
LineStart: []string{"home", "ctrl+a"}, LineEnd: []string{"end", "ctrl+e"},
},
Vim: VimKeyBindings{
Insert: []string{"i"}, Append: []string{"a"},
InsertLineStart: []string{"I"}, AppendLineEnd: []string{"A"},
OpenBelow: []string{"o"}, OpenAbove: []string{"O"},
ReplaceCharacter: []string{"s"}, Visual: []string{"v"}, VisualLine: []string{"V"},
SelectionOtherEnd: []string{"o"}, Yank: []string{"y"},
Delete: []string{"d", "x", "delete"}, DeleteBefore: []string{"X", "backspace"},
Paste: []string{"p"}, LineStart: []string{"0", "home"},
FirstNonBlank: []string{"^"}, LineEnd: []string{"$", "end"},
WordForward: []string{"w"}, WORDForward: []string{"W"},
WordBackward: []string{"b"}, WORDBackward: []string{"B"},
WordEnd: []string{"e"}, WORDEnd: []string{"E"}, GoPrefix: []string{"g"},
FindForward: []string{"f"}, FindBackward: []string{"F"},
TillForward: []string{"t"}, TillBackward: []string{"T"},
RepeatFind: []string{";"}, RepeatFindReverse: []string{","},
},
}
}
func keyMatches(key string, bindings []string) bool {
for _, binding := range bindings {
if key == binding {
return true
}
}
return false
}
func keyLabel(bindings []string) string {
return strings.Join(bindings, " / ")
}
func primaryKeyLabel(bindings []string) string {
if len(bindings) == 0 {
return ""
}
return bindings[0]
}
func primaryCombinedKeyLabel(groups ...[]string) string {
keys := make([]string, 0, len(groups))
for _, group := range groups {
if key := primaryKeyLabel(group); key != "" {
keys = append(keys, key)
}
}
return strings.Join(keys, " / ")
}
func primarySequenceKeyLabel(prefixes, suffixes []string) string {
return primaryKeyLabel(prefixes) + primaryKeyLabel(suffixes)
}
func combinedKeyLabel(groups ...[]string) string {
var keys []string
for _, group := range groups {
keys = append(keys, group...)
}
return keyLabel(keys)
}
func sequenceKeyLabel(prefixes, suffixes []string) string {
var sequences []string
for _, prefix := range prefixes {
for _, suffix := range suffixes {
sequences = append(sequences, prefix+suffix)
}
}
return keyLabel(sequences)
}
func (k KeyBindings) canonicalHelpKey(key string) string {
switch {
case keyMatches(key, k.General.Quit):
return "ctrl+c"
case keyMatches(key, k.General.Help), keyMatches(key, k.General.Back):
return "esc"
case keyMatches(key, k.Navigation.Down):
return "j"
case keyMatches(key, k.Navigation.Up):
return "k"
case keyMatches(key, k.Navigation.First):
return "g"
case keyMatches(key, k.Navigation.Last):
return "G"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
default:
return ""
}
}
func (k KeyBindings) canonicalSearchKey(key string) string {
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.Input.Newline):
return "enter"
case keyMatches(key, k.Input.PreviousCompletion):
return "up"
case keyMatches(key, k.Input.NextCompletion):
return "down"
case keyMatches(key, k.Input.DeleteBackward):
return "backspace"
case keyMatches(key, k.Input.Clear):
return "ctrl+u"
default:
return ""
}
}
func (k KeyBindings) canonicalWriteKey(key string) string {
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.General.Confirm):
return "y"
case keyMatches(key, k.General.Reject):
return "n"
case keyMatches(key, k.Input.Submit):
return "ctrl+s"
case keyMatches(key, k.Input.Newline):
return "enter"
case keyMatches(key, k.Input.DeleteBackward):
return "backspace"
default:
return ""
}
}
func (k KeyBindings) canonicalMainKey(key string, current screen) string {
switch {
case keyMatches(key, k.General.Quit):
return "q"
case keyMatches(key, k.General.Help):
return "?"
case keyMatches(key, k.General.Refresh):
return "r"
case keyMatches(key, k.General.Back):
return "esc"
case keyMatches(key, k.Navigation.Down):
return "j"
case keyMatches(key, k.Navigation.Up):
return "k"
case keyMatches(key, k.Navigation.First):
return "g"
case keyMatches(key, k.Navigation.Last):
return "G"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
}
if current == prScreen || current == dashboardScreen {
if keyMatches(key, k.Views.Open) {
return "enter"
}
}
if current == dashboardScreen {
switch {
case keyMatches(key, k.Views.AutoMerge):
return "a"
case keyMatches(key, k.Views.MergeNow):
return "M"
}
}
if current == threadScreen {
switch {
case keyMatches(key, k.Navigation.Left):
return "h"
case keyMatches(key, k.Navigation.Right):
return "l"
case keyMatches(key, k.Views.ToggleList):
return "tab"
case keyMatches(key, k.Threads.Search):
return "/"
case keyMatches(key, k.Threads.ClearFilter):
return "F"
case keyMatches(key, k.Threads.NextUnread):
return "n"
case keyMatches(key, k.Threads.PreviousUnread):
return "N"
case keyMatches(key, k.Threads.Reply):
return "c"
case keyMatches(key, k.Threads.Resolve):
return "R"
case keyMatches(key, k.Threads.Toggle):
return "enter"
case keyMatches(key, k.Threads.FoldPrefix):
return "z"
}
}
switch {
case keyMatches(key, k.Views.Dashboard):
return "d"
case keyMatches(key, k.Views.Health):
return "H"
case keyMatches(key, k.Views.Edit):
return "e"
default:
return ""
}
}
func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool) string {
if confirming {
switch {
case keyMatches(key, k.General.Confirm):
return "y"
case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel):
return "esc"
}
}
switch {
case keyMatches(key, k.Input.Cancel):
return "esc"
case keyMatches(key, k.Input.Submit):
return "ctrl+s"
case keyMatches(key, k.Input.NextField):
return "tab"
case keyMatches(key, k.Input.PreviousField):
return "shift+tab"
case keyMatches(key, k.Input.NextCompletion):
return "ctrl+n"
case keyMatches(key, k.Input.PreviousCompletion):
return "ctrl+p"
case keyMatches(key, k.Navigation.PageDown):
return "ctrl+d"
case keyMatches(key, k.Navigation.PageUp):
return "ctrl+u"
}
if field != prEditBodyField {
switch {
case keyMatches(key, k.Input.Newline):
return "enter"
}
}
return ""
}
func validateKeyBindings(bindings KeyBindings) error {
groups := []struct {
name string
values map[string][]string
}{
{"keybindings.general", map[string][]string{
"quit": bindings.General.Quit, "help": bindings.General.Help,
"refresh": bindings.General.Refresh, "back": bindings.General.Back,
"confirm": bindings.General.Confirm, "reject": bindings.General.Reject,
}},
{"keybindings.navigation", map[string][]string{
"down": bindings.Navigation.Down, "up": bindings.Navigation.Up,
"left": bindings.Navigation.Left, "right": bindings.Navigation.Right,
"first": bindings.Navigation.First, "last": bindings.Navigation.Last,
"page_down": bindings.Navigation.PageDown, "page_up": bindings.Navigation.PageUp,
}},
{"keybindings.views", map[string][]string{
"open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard,
"health": bindings.Views.Health, "edit": bindings.Views.Edit,
"auto_merge": bindings.Views.AutoMerge, "merge_now": bindings.Views.MergeNow,
"toggle_list": bindings.Views.ToggleList,
}},
{"keybindings.threads", map[string][]string{
"search": bindings.Threads.Search, "clear_filter": bindings.Threads.ClearFilter,
"next_unread": bindings.Threads.NextUnread,
"previous_unread": bindings.Threads.PreviousUnread,
"reply": bindings.Threads.Reply, "resolve": bindings.Threads.Resolve,
"toggle": bindings.Threads.Toggle, "fold_prefix": bindings.Threads.FoldPrefix,
"fold_toggle": bindings.Threads.FoldToggle,
}},
{"keybindings.input", map[string][]string{
"cancel": bindings.Input.Cancel,
"submit": bindings.Input.Submit, "newline": bindings.Input.Newline,
"delete_backward": bindings.Input.DeleteBackward,
"delete_forward": bindings.Input.DeleteForward, "clear": bindings.Input.Clear,
"next_field": bindings.Input.NextField, "previous_field": bindings.Input.PreviousField,
"next_completion": bindings.Input.NextCompletion,
"previous_completion": bindings.Input.PreviousCompletion,
"line_start": bindings.Input.LineStart, "line_end": bindings.Input.LineEnd,
}},
{"keybindings.vim", map[string][]string{
"insert": bindings.Vim.Insert, "append": bindings.Vim.Append,
"insert_line_start": bindings.Vim.InsertLineStart,
"append_line_end": bindings.Vim.AppendLineEnd,
"open_below": bindings.Vim.OpenBelow, "open_above": bindings.Vim.OpenAbove,
"replace_character": bindings.Vim.ReplaceCharacter,
"visual": bindings.Vim.Visual, "visual_line": bindings.Vim.VisualLine,
"selection_other_end": bindings.Vim.SelectionOtherEnd,
"yank": bindings.Vim.Yank, "delete": bindings.Vim.Delete,
"delete_before": bindings.Vim.DeleteBefore, "paste": bindings.Vim.Paste,
"line_start": bindings.Vim.LineStart,
"first_non_blank": bindings.Vim.FirstNonBlank, "line_end": bindings.Vim.LineEnd,
"word_forward": bindings.Vim.WordForward, "big_word_forward": bindings.Vim.WORDForward,
"word_backward": bindings.Vim.WordBackward, "big_word_backward": bindings.Vim.WORDBackward,
"word_end": bindings.Vim.WordEnd, "big_word_end": bindings.Vim.WORDEnd,
"go_prefix": bindings.Vim.GoPrefix,
"find_forward": bindings.Vim.FindForward, "find_backward": bindings.Vim.FindBackward,
"till_forward": bindings.Vim.TillForward, "till_backward": bindings.Vim.TillBackward,
"repeat_find": bindings.Vim.RepeatFind,
"repeat_find_reverse": bindings.Vim.RepeatFindReverse,
}},
}
for _, group := range groups {
for action, keys := range group.values {
if len(keys) == 0 {
return fmt.Errorf("%s.%s must contain at least one key", group.name, action)
}
seen := make(map[string]bool)
for _, key := range keys {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("%s.%s contains an empty key", group.name, action)
}
if seen[key] {
return fmt.Errorf("%s.%s contains duplicate key %q", group.name, action, key)
}
seen[key] = true
}
}
}
return validateKeyBindingContexts(bindings)
}
type contextBinding struct {
action string
keys []string
}
func validateKeyBindingContexts(bindings KeyBindings) error {
navigation := bindings.Navigation
general := bindings.General
views := bindings.Views
threads := bindings.Threads
input := bindings.Input
vim := bindings.Vim
screenCommon := []contextBinding{
{"quit", general.Quit}, {"help", general.Help}, {"refresh", general.Refresh},
{"back", general.Back}, {"down", navigation.Down}, {"up", navigation.Up},
{"first", navigation.First}, {"last", navigation.Last},
{"page_down", navigation.PageDown}, {"page_up", navigation.PageUp},
{"health", views.Health},
}
if err := validateKeyContext("pull request list", append(screenCommon,
contextBinding{"open", views.Open},
contextBinding{"dashboard", views.Dashboard},
)...); err != nil {
return err
}
if err := validateKeyContext("dashboard", append(screenCommon,
contextBinding{"open", views.Open},
contextBinding{"edit", views.Edit},
contextBinding{"auto_merge", views.AutoMerge},
contextBinding{"merge_now", views.MergeNow},
)...); err != nil {
return err
}
if err := validateKeyContext("health screen", screenCommon...); err != nil {
return err
}
if err := validateKeyContext("review threads", append(screenCommon,
contextBinding{"left", navigation.Left},
contextBinding{"right", navigation.Right},
contextBinding{"toggle_list", views.ToggleList},
contextBinding{"dashboard", views.Dashboard},
contextBinding{"search", threads.Search},
contextBinding{"clear_filter", threads.ClearFilter},
contextBinding{"next_unread", threads.NextUnread},
contextBinding{"previous_unread", threads.PreviousUnread},
contextBinding{"reply", threads.Reply},
contextBinding{"resolve", threads.Resolve},
contextBinding{"toggle", threads.Toggle},
contextBinding{"fold_prefix", threads.FoldPrefix},
)...); err != nil {
return err
}
if err := validateKeyContext("help popup",
contextBinding{"quit", general.Quit},
contextBinding{"close_help", appendCopy(general.Help, general.Back...)},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"first", navigation.First},
contextBinding{"last", navigation.Last},
contextBinding{"page_down", navigation.PageDown},
contextBinding{"page_up", navigation.PageUp},
); err != nil {
return err
}
if err := validateKeyContext("search input",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"cancel", input.Cancel},
contextBinding{"apply", input.Newline},
contextBinding{"previous_completion", input.PreviousCompletion},
contextBinding{"next_completion", input.NextCompletion},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"clear", input.Clear},
); err != nil {
return err
}
if err := validateKeyContext("reply input",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"cancel", input.Cancel},
contextBinding{"submit", input.Submit},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
); err != nil {
return err
}
if err := validateKeyContext("confirmation",
contextBinding{"quit", nonTextBindings(general.Quit)},
contextBinding{"confirm", general.Confirm},
contextBinding{"cancel", appendCopy(general.Reject, input.Cancel...)},
); err != nil {
return err
}
editorOuter := []contextBinding{
{"help", general.Help},
{"cancel", input.Cancel},
{"submit", input.Submit},
{"next_field", input.NextField},
{"previous_field", input.PreviousField},
{"page_down", navigation.PageDown},
{"page_up", navigation.PageUp},
}
normalEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", navigation.Left},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"right", navigation.Right},
contextBinding{"last", navigation.Last},
contextBinding{"insert", vim.Insert},
contextBinding{"append", vim.Append},
contextBinding{"insert_line_start", vim.InsertLineStart},
contextBinding{"append_line_end", vim.AppendLineEnd},
contextBinding{"open_below", vim.OpenBelow},
contextBinding{"open_above", vim.OpenAbove},
contextBinding{"replace_character", vim.ReplaceCharacter},
contextBinding{"visual", vim.Visual},
contextBinding{"visual_line", vim.VisualLine},
contextBinding{"paste", vim.Paste},
contextBinding{"line_start", vim.LineStart},
contextBinding{"first_non_blank", vim.FirstNonBlank},
contextBinding{"line_end", vim.LineEnd},
contextBinding{"word_forward", vim.WordForward},
contextBinding{"big_word_forward", vim.WORDForward},
contextBinding{"word_backward", vim.WordBackward},
contextBinding{"big_word_backward", vim.WORDBackward},
contextBinding{"word_end", vim.WordEnd},
contextBinding{"big_word_end", vim.WORDEnd},
contextBinding{"go_prefix", vim.GoPrefix},
contextBinding{"find_forward", vim.FindForward},
contextBinding{"find_backward", vim.FindBackward},
contextBinding{"till_forward", vim.TillForward},
contextBinding{"till_backward", vim.TillBackward},
contextBinding{"repeat_find", vim.RepeatFind},
contextBinding{"repeat_find_reverse", vim.RepeatFindReverse},
contextBinding{"delete", vim.Delete},
contextBinding{"delete_before", vim.DeleteBefore},
)
if err := validateKeyContext("Vim Normal mode", normalEditor...); err != nil {
return err
}
visualEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", navigation.Left},
contextBinding{"down", navigation.Down},
contextBinding{"up", navigation.Up},
contextBinding{"right", navigation.Right},
contextBinding{"last", navigation.Last},
contextBinding{"visual", vim.Visual},
contextBinding{"visual_line", vim.VisualLine},
contextBinding{"selection_other_end", vim.SelectionOtherEnd},
contextBinding{"yank", vim.Yank},
contextBinding{"delete", vim.Delete},
contextBinding{"paste", vim.Paste},
contextBinding{"line_start", vim.LineStart},
contextBinding{"first_non_blank", vim.FirstNonBlank},
contextBinding{"line_end", vim.LineEnd},
contextBinding{"word_forward", vim.WordForward},
contextBinding{"big_word_forward", vim.WORDForward},
contextBinding{"word_backward", vim.WordBackward},
contextBinding{"big_word_backward", vim.WORDBackward},
contextBinding{"word_end", vim.WordEnd},
contextBinding{"big_word_end", vim.WORDEnd},
contextBinding{"go_prefix", vim.GoPrefix},
contextBinding{"find_forward", vim.FindForward},
contextBinding{"find_backward", vim.FindBackward},
contextBinding{"till_forward", vim.TillForward},
contextBinding{"till_backward", vim.TillBackward},
contextBinding{"repeat_find", vim.RepeatFind},
contextBinding{"repeat_find_reverse", vim.RepeatFindReverse},
)
if err := validateKeyContext("Vim Visual mode", visualEditor...); err != nil {
return err
}
insertEditor := append(append([]contextBinding(nil), editorOuter...),
contextBinding{"left", nonTextBindings(navigation.Left)},
contextBinding{"down", nonTextBindings(navigation.Down)},
contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"right", nonTextBindings(navigation.Right)},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
)
if err := validateKeyContext("editor Insert mode", insertEditor...); err != nil {
return err
}
return validateKeyContext("single-line editor input",
contextBinding{"help", nonTextBindings(general.Help)},
contextBinding{"cancel", input.Cancel},
contextBinding{"submit", input.Submit},
contextBinding{"next_field", input.NextField},
contextBinding{"previous_field", input.PreviousField},
contextBinding{"previous_completion", input.PreviousCompletion},
contextBinding{"next_completion", input.NextCompletion},
contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"down", nonTextBindings(navigation.Down)},
)
}
func validateKeyContext(context string, bindings ...contextBinding) error {
assigned := make(map[string]string)
for _, binding := range bindings {
for _, key := range binding.keys {
if previous, exists := assigned[key]; exists && previous != binding.action {
return fmt.Errorf(
"keybinding conflict in %s: key %q is assigned to both %s and %s",
context, key, previous, binding.action,
)
}
assigned[key] = binding.action
}
}
return nil
}
func nonTextBindings(bindings []string) []string {
var filtered []string
for _, binding := range bindings {
if utf8.RuneCountInString(binding) != 1 {
filtered = append(filtered, binding)
}
}
return filtered
}
func appendCopy(bindings []string, more ...string) []string {
result := append([]string(nil), bindings...)
return append(result, more...)
}

74
main.go
View File

@@ -4,6 +4,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -20,19 +21,29 @@ func main() {
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)") repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval") poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you") showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you")
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-100)") limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)")
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint") endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint")
theme = flag.String("theme", defaults.Theme, "color theme: dark or light") theme = flag.String("theme", defaults.Theme, "color theme: dark, light, high-contrast, or no-color")
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded") foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)") listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate submitted review history")
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths") pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval") pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback")
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "maximum offline cache age (0 disables expiry)")
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read cache directory")
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode: standard or vim")
) )
flag.Parse() flag.Parse()
visited := map[string]bool{} visited := map[string]bool{}
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true }) flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
config, err := loadConfig(*configFile, visited["config"] || os.Getenv("GH_THREADS_CONFIG") != "") config, err := loadConfig(
*configFile,
visited["config"] || os.Getenv("DIPLE_CONFIG") != "" ||
os.Getenv("GH_THREADS_CONFIG") != "",
)
if err != nil { if err != nil {
exitf("configuration: %v", err) exitf("configuration: %v", err)
} }
@@ -62,12 +73,30 @@ func main() {
if visited["thread-list-width"] { if visited["thread-list-width"] {
config.Display.ThreadListWidthPercent = *listWidth config.Display.ThreadListWidthPercent = *listWidth
} }
if visited["dashboard-mode"] {
config.Display.DashboardMode = *dashboardMode
}
if visited["compact-reviews"] {
config.Display.CompactReviews = *compactReviews
}
if visited["path-scroll"] { if visited["path-scroll"] {
config.Paths.Scroll = *pathScroll config.Paths.Scroll = *pathScroll
} }
if visited["path-scroll-interval"] { if visited["path-scroll-interval"] {
config.Paths.ScrollInterval.Duration = *pathScrollRate config.Paths.ScrollInterval.Duration = *pathScrollRate
} }
if visited["cache"] {
config.Cache.Enabled = *cacheEnabled
}
if visited["cache-max-age"] {
config.Cache.MaxAge.Duration = *cacheMaxAge
}
if visited["cache-dir"] {
config.Cache.Directory = *cacheDir
}
if visited["editor-mode"] {
config.Editing.Mode = *editorMode
}
if err := validateConfig(config); err != nil { if err := validateConfig(config); err != nil {
exitf("configuration: %v", err) exitf("configuration: %v", err)
} }
@@ -89,18 +118,45 @@ func main() {
} }
client := NewGitHubClient(config.Endpoint, token) client := NewGitHubClient(config.Endpoint, token)
var service GitHubService = client
if config.Cache.Enabled {
cacheDir := config.Cache.Directory
if cacheDir == "" {
cacheDir, err = defaultCacheDir()
if err != nil {
exitf("configuration: %v", err)
}
}
service = NewCachedGitHubService(
client, cacheDir, config.Cache.MaxAge.Duration, config.Cache.MaxEntries,
)
}
statePath := filepath.Join(filepath.Dir(*configFile), "state.json")
draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json")
app := NewAppWithSettings( app := NewAppWithSettings(
client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration, service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
AppSettings{ AppSettings{
FoldResolved: config.Display.FoldResolved, FoldResolved: config.Display.FoldResolved,
ThreadListWidthPercent: config.Display.ThreadListWidthPercent, ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
DashboardMode: config.Display.DashboardMode,
CompactReviews: config.Display.CompactReviews,
ReadState: loadReadState(statePath),
Drafts: loadDraftStore(draftPath),
PathScroll: config.Paths.Scroll, PathScroll: config.Paths.Scroll,
PathScrollInterval: config.Paths.ScrollInterval.Duration, PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder, ThreadStatusOrder: config.Threads.StatusOrder,
ThreadWithinStatus: config.Threads.WithinStatus, ThreadWithinStatus: config.Threads.WithinStatus,
EditorMode: config.Editing.Mode,
KeyBindings: config.KeyBindings,
}, },
) )
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil { cursorOutput := newTerminalCursorOutput(os.Stdout)
app.cursorOutput = cursorOutput
if _, err := tea.NewProgram(
app,
tea.WithAltScreen(),
tea.WithOutput(cursorOutput),
).Run(); err != nil {
exitf("run TUI: %v", err) exitf("run TUI: %v", err)
} }
} }
@@ -115,9 +171,15 @@ func firstNonEmpty(values ...string) string {
} }
func exitf(format string, args ...any) { func exitf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "gh-threads: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "diple: "+format+"\n", args...)
os.Exit(1) os.Exit(1)
} }
// Keep interface drift visible at compile time. // Keep interface drift visible at compile time.
var _ GitHubService = (*GitHubClient)(nil) var _ GitHubService = (*GitHubClient)(nil)
var _ GitHubMergeService = (*GitHubClient)(nil)
var _ GitHubMergeService = (*CachedGitHubService)(nil)
var _ GitHubWriteService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*CachedGitHubService)(nil)
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)

View File

@@ -82,6 +82,8 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
style := styles.DarkStyleConfig style := styles.DarkStyleConfig
if markdownStyleName == "light" { if markdownStyleName == "light" {
style = styles.LightStyleConfig style = styles.LightStyleConfig
} else if markdownStyleName == "notty" {
style = styles.NoTTYStyleConfig
} }
zero := uint(0) zero := uint(0)
style.Document.Margin = &zero style.Document.Margin = &zero

View File

@@ -0,0 +1,182 @@
package main
import (
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
)
type editorMarkdownStyle uint8
const (
editorMarkdownPlain editorMarkdownStyle = iota
editorMarkdownHeading
editorMarkdownStrong
editorMarkdownEmphasis
editorMarkdownCode
editorMarkdownLink
editorMarkdownDestination
editorMarkdownQuote
editorMarkdownComment
)
func editorMarkdownStyles(value string) []editorMarkdownStyle {
runes := []rune(value)
styles := make([]editorMarkdownStyle, len(runes))
lexer := lexers.Get("markdown")
if lexer == nil {
highlightEditorHTMLComments(runes, styles)
return styles
}
tokens, err := chroma.Tokenise(lexer, nil, value)
if err != nil {
highlightEditorHTMLComments(runes, styles)
return styles
}
offset := 0
for _, token := range tokens {
style := editorMarkdownStyleForToken(token.Type)
for range []rune(token.Value) {
if offset >= len(styles) {
return styles
}
styles[offset] = style
offset++
}
}
highlightEditorHTMLComments(runes, styles)
return styles
}
func highlightEditorHTMLComments(runes []rune, styles []editorMarkdownStyle) {
startMarker, endMarker := []rune("<!--"), []rune("-->")
for offset := 0; offset < len(runes); {
start := findEditorRunes(runes, startMarker, offset)
if start < 0 {
return
}
end := findEditorRunes(runes, endMarker, start+len(startMarker))
if end < 0 {
end = len(runes)
} else {
end += len(endMarker)
}
for index := start; index < end; index++ {
styles[index] = editorMarkdownComment
}
offset = end
}
}
func findEditorRunes(value, target []rune, offset int) int {
for index := max(0, offset); index+len(target) <= len(value); index++ {
matches := true
for targetIndex := range target {
if value[index+targetIndex] != target[targetIndex] {
matches = false
break
}
}
if matches {
return index
}
}
return -1
}
func editorMarkdownStyleForToken(token chroma.TokenType) editorMarkdownStyle {
switch {
case token == chroma.GenericHeading || token == chroma.GenericSubheading:
return editorMarkdownHeading
case token == chroma.GenericStrong:
return editorMarkdownStrong
case token == chroma.GenericEmph:
return editorMarkdownEmphasis
case token == chroma.LiteralStringBacktick:
return editorMarkdownCode
case token == chroma.NameTag:
return editorMarkdownLink
case token == chroma.NameAttribute:
return editorMarkdownDestination
case token == chroma.Keyword:
return editorMarkdownQuote
case token.InSubCategory(chroma.Comment):
return editorMarkdownComment
default:
return editorMarkdownPlain
}
}
func editorMarkdownStyleStart(style editorMarkdownStyle) string {
if style == editorMarkdownPlain {
return ""
}
if currentThemeName == "no-color" {
switch style {
case editorMarkdownHeading, editorMarkdownStrong:
return "\x1b[1m"
case editorMarkdownEmphasis:
return "\x1b[3m"
case editorMarkdownLink:
return "\x1b[4m"
case editorMarkdownComment:
return "\x1b[2m"
default:
return ""
}
}
if currentThemeName == "light" {
switch style {
case editorMarkdownHeading:
return "\x1b[1;38;2;154;103;0m"
case editorMarkdownStrong:
return "\x1b[1;38;2;130;80;223m"
case editorMarkdownEmphasis:
return "\x1b[3;38;2;87;96;106m"
case editorMarkdownCode:
return "\x1b[38;2;17;99;41m"
case editorMarkdownLink:
return "\x1b[4;38;2;9;105;218m"
case editorMarkdownDestination:
return "\x1b[38;2;10;112;111m"
case editorMarkdownQuote:
return "\x1b[38;2;154;103;0m"
case editorMarkdownComment:
return "\x1b[2;38;2;101;109;118m"
}
}
switch style {
case editorMarkdownHeading:
return "\x1b[1;38;2;240;183;47m"
case editorMarkdownStrong:
return "\x1b[1;38;2;198;120;221m"
case editorMarkdownEmphasis:
return "\x1b[3;38;2;215;218;232m"
case editorMarkdownCode:
return "\x1b[38;2;152;195;121m"
case editorMarkdownLink:
return "\x1b[4;38;2;97;175;239m"
case editorMarkdownDestination:
return "\x1b[38;2;86;182;194m"
case editorMarkdownQuote:
return "\x1b[38;2;229;192;123m"
case editorMarkdownComment:
return "\x1b[2;38;2;119;119;119m"
default:
return ""
}
}
func editorMarkdownStyleEnd(active bool) string {
foreground := "\x1b[39m"
if active {
switch currentThemeName {
case "dark":
foreground = "\x1b[38;2;215;218;232m"
case "light":
foreground = "\x1b[38;2;36;41;47m"
case "high-contrast":
foreground = "\x1b[38;2;255;255;255m"
}
}
return "\x1b[22;23;24m" + foreground
}

53
persistence.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
func atomicWriteJSON(path string, value any, mode os.FileMode) error {
if path == "" {
return nil
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
temp, err := os.CreateTemp(dir, ".diple-*")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if err := temp.Chmod(mode); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
if directory, err := os.Open(dir); err == nil {
defer directory.Close()
if syncErr := directory.Sync(); syncErr != nil && !errors.Is(syncErr, os.ErrInvalid) {
return syncErr
}
}
return nil
}

478
pr_editor.go Normal file
View File

@@ -0,0 +1,478 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
const (
prEditTitleField = iota
prEditBaseField
prEditBodyField
prEditFieldCount
)
func (m *App) startPREdit() tea.Cmd {
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
m.err = errors.New(reason)
return nil
}
m.writeMode = writePREdit
m.prEditField = prEditBodyField
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(m.details.Body),
m.editorMode == "vim",
)
m.prEditEditors[prEditBodyField].highlightMarkdown = true
m.prEditOriginal = m.currentPRMetadata()
m.restorePREditDraft()
for index := range m.prEditEditors {
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
m.prEditEditors[index].keys = m.keybindings
}
m.prEditBranches = nil
m.prEditBranchesLoading = false
m.prEditBranchesError = ""
m.prEditBranchIndex = 0
m.scroll = 0
m.err = m.prEditEditors[m.prEditField].err
m.prEditEditors[m.prEditField].err = nil
m.ensurePREditCursorVisible()
return m.loadPREditBranches()
}
func (m *App) loadPREditBranches() tea.Cmd {
service, ok := m.service.(GitHubBranchService)
if !ok {
m.prEditBranchesError = "configured GitHub service cannot list branches"
return nil
}
m.prEditBranchesLoading = true
owner, repo := m.details.Owner, m.details.Repository
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
branches, err := service.ListBranches(ctx, owner, repo)
return branchesLoadedMsg{owner: owner, repo: repo, branches: branches, err: err}
}
}
func (m App) pullRequestUpdateUnavailable() string {
if m.loading {
return "pull request update unavailable while PR data is refreshing"
}
if m.details.FromCache {
return "pull request update unavailable from an offline cached snapshot"
}
if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
return "configured GitHub service does not support pull request updates"
}
if m.details.ID == "" {
return "pull request details are not loaded"
}
if !m.details.Permissions.CanUpdatePR {
return "GitHub did not grant update permission for this pull request"
}
return ""
}
func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keybindings.canonicalPREditKey(
key.String(), m.prEditField, m.writeMode == writePREditConfirm,
)
if m.prEditField != prEditBodyField &&
key.Type != tea.KeyRunes && key.Type != tea.KeySpace {
switch {
case keyMatches(key.String(), m.keybindings.Navigation.Up):
k = "up"
case keyMatches(key.String(), m.keybindings.Navigation.Down):
k = "down"
}
}
editorWidth := m.prEditEditorWidth()
if m.writeMode == writePREditConfirm {
switch k {
case "y":
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
m.writeMode = writePREdit
m.err = errors.New(reason)
m.scroll = 0
return m, nil
}
if m.prEditIsStale() {
m.writeMode = writePREdit
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
m.scroll = 0
return m, nil
}
m.writeMode = writePREditBusy
return m, m.submitPREdit()
case "n", "esc":
m.writeMode = writePREdit
m.ensurePREditCursorVisible()
}
return m, nil
}
switch k {
case "ctrl+s":
if err := m.validatePREdit(); err != nil {
m.err = err
m.scroll = 0
return m, nil
} else if m.prEditIsStale() {
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
m.scroll = 0
return m, nil
} else {
m.writeMode = writePREditConfirm
m.err = nil
return m, nil
}
case "tab":
if m.prEditField != prEditBaseField || !m.completeBranchSuggestion() {
m.movePREditField(1)
}
case "shift+tab":
m.movePREditField(-1)
case "ctrl+n":
if m.prEditField == prEditBaseField {
m.moveBranchSuggestion(1)
}
case "ctrl+p":
if m.prEditField == prEditBaseField {
m.moveBranchSuggestion(-1)
}
case "ctrl+d", "ctrl+u":
if m.prEditField == prEditBodyField {
direction := 1
if k == "ctrl+u" {
direction = -1
}
delta := direction * max(1, m.dashboardViewportHeight()/2)
m.prEditEditors[m.prEditField].movePage(delta, editorWidth)
m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll())
}
case "enter":
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
break
}
if m.prEditField != prEditBodyField {
m.movePREditField(1)
} else {
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
}
case "up":
if m.prEditField != prEditBodyField {
m.movePREditField(-1)
} else {
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
}
case "down":
if m.prEditField != prEditBodyField {
m.movePREditField(1)
} else {
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
}
case "esc":
editor := &m.prEditEditors[m.prEditField]
if editor.Modal && editor.Mode != textEditorNormal {
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
} else {
m.writeMode = writeNone
m.clearPREdit()
m.err = nil
m.scroll = 0
return m, nil
}
default:
editor := &m.prEditEditors[m.prEditField]
before := editor.Text
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
if m.prEditField != prEditBodyField {
editor.Text = normalizeSingleLine(editor.Text)
editor.Cursor = clamp(editor.Cursor, 0, len([]rune(editor.Text)))
}
if m.prEditField == prEditBaseField && editor.Text != before {
m.prEditBranchIndex = 0
}
}
m.err = nil
m.ensurePREditCursorVisible()
return m, m.queuePREditDraft()
}
func (m App) prEditEditorWidth() int {
return max(1, max(10, m.width-2)-4)
}
func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
if m.cursorOutput == nil {
return
}
editor := m.prEditEditors[m.prEditField]
if editor.Mode != textEditorInsert {
return
}
_, cursorLine := m.dashboardEditLayout()
screenRow := cursorLine - scroll
if screenRow < 0 || screenRow >= viewportHeight {
return
}
_, column := editorCursorVisualPosition(editor, m.prEditEditorWidth())
// Rows and columns are one-based. Each editor row has a two-cell "│ "
// context rail before its text.
m.cursorOutput.SetCursor(true, column+3, screenRow+1)
}
func (m App) submitPREdit() tea.Cmd {
writer := m.service.(GitHubPullRequestWriteService)
id := m.details.ID
update := m.prEditMetadata()
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
metadata, err := writer.UpdatePullRequest(ctx, id, update)
return pullRequestUpdatedMsg{metadata: metadata, err: err}
}
}
func (m App) validatePREdit() error {
update := m.prEditMetadata()
if update.Title == "" {
return errors.New("pull request title cannot be empty")
}
if update.BaseRef == "" {
return errors.New("target branch cannot be empty")
}
if len(m.prEditBranches) > 0 {
found := false
for _, branch := range m.prEditBranches {
if branch.Name == update.BaseRef {
found = true
break
}
}
if !found {
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
}
}
if samePRMetadata(update, m.prEditOriginal) {
return errors.New("title, target branch, and description are unchanged")
}
return nil
}
func (m App) prEditIsStale() bool {
return !samePRMetadata(m.currentPRMetadata(), m.prEditOriginal)
}
func (m App) currentPRMetadata() PullRequestMetadata {
return PullRequestMetadata{
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
UpdatedAt: m.details.UpdatedAt,
}
}
func (m App) prEditMetadata() PullRequestMetadata {
body := m.prEditEditors[prEditBodyField].Text
if body == normalizeLineEndings(m.prEditOriginal.Body) {
// Opening the editor must not turn mixed or CRLF line endings into an
// apparent edit. Preserve the remote body exactly until its content is
// actually changed.
body = m.prEditOriginal.Body
}
return PullRequestMetadata{
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
Body: body,
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
}
}
func samePRMetadata(left, right PullRequestMetadata) bool {
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
}
func (m *App) clearPREdit() {
m.prEditField = 0
m.prEditEditors = [3]textEditor{}
m.prEditOriginal = PullRequestMetadata{}
m.prEditBranches = nil
m.prEditBranchesLoading = false
m.prEditBranchesError = ""
m.prEditBranchIndex = 0
}
func (m *App) movePREditField(delta int) {
m.prEditField = (m.prEditField + delta + prEditFieldCount) % prEditFieldCount
}
func textLineStart(value string, cursor int) int {
runes := []rune(value)
cursor = clamp(cursor, 0, len(runes))
for cursor > 0 && runes[cursor-1] != '\n' {
cursor--
}
return cursor
}
func textLineEnd(value string, cursor int) int {
runes := []rune(value)
cursor = clamp(cursor, 0, len(runes))
for cursor < len(runes) && runes[cursor] != '\n' {
cursor++
}
return cursor
}
func moveTextCursorLine(value string, cursor, delta int) int {
start := textLineStart(value, cursor)
column := cursor - start
if delta < 0 {
if start == 0 {
return cursor
}
previousEnd := start - 1
previousStart := textLineStart(value, previousEnd)
return min(previousStart+column, previousEnd)
}
end := textLineEnd(value, cursor)
if end == len([]rune(value)) {
return cursor
}
nextStart := end + 1
nextEnd := textLineEnd(value, nextStart)
return min(nextStart+column, nextEnd)
}
func (m App) dashboardEditLines() []string {
lines, _ := m.dashboardEditLayout()
return lines
}
func (m App) dashboardEditLayout() ([]string, int) {
width := max(10, m.width-2)
cursorLine := 0
lines := []string{
titleStyle.Render(fmt.Sprintf("%s #%d", m.details.RepoWithOwner, m.details.Number)) +
" " + warnStyle.Render("EDITING"),
"",
titleStyle.Render("Edit pull request"),
dimStyle.Render("Raw Markdown is preserved in the description."),
}
if m.err != nil {
errorWidth := max(1, width-2)
wrapped := ansi.Hardwrap(ansi.Wordwrap(m.err.Error(), errorWidth, ""), errorWidth, false)
lines = append(lines, "")
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, badStyle.Render(line))
}
}
appendField := func(label string, field int) {
lines = append(lines, "")
start := len(lines)
lines = append(lines, m.prEditFieldLines(label, field, width)...)
if m.prEditField == field {
cursorLine = start + 1 + editorCursorVisualLine(m.prEditEditors[field], max(1, width-4))
}
}
appendField("title", prEditTitleField)
appendField("target branch", prEditBaseField)
appendField("description", prEditBodyField)
return lines, cursorLine
}
func (m App) prEditFieldLines(label string, field, width int) []string {
active := m.prEditField == field
editor := m.prEditEditors[field]
prefix := " "
if active {
prefix = "▶ "
}
mode := editor.modeLabel()
if mode != "" {
label += " [" + mode + "]"
}
labelLine := dimStyle.Render(prefix + label)
if active {
labelLine = titleStyle.Render(prefix + label)
}
textWidth := max(1, width-4)
rendered := renderTextEditor(editor, textWidth, active)
lines := []string{labelLine}
for _, line := range rendered {
if line.active {
// Style the rail and text as one row. Nesting the cursor or rail
// style inside a background style emits resets that can erase the
// remainder of wrapped terminal rows.
lines = append(lines, editorLineStyle.Render("│ "+line.text))
continue
}
lines = append(lines, dimStyle.Render("│ ")+line.text)
}
if active && field == prEditBaseField {
lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
}
return lines
}
func (m *App) ensurePREditCursorVisible() {
if m.writeMode != writePREdit {
return
}
lines, cursorLine := m.dashboardEditLayout()
height := m.dashboardViewportHeight()
if m.prEditField == prEditTitleField && cursorLine < height {
// The title is the first editable field. Returning to it should also
// restore the dashboard/editor heading instead of pinning the title's
// text row to the top and clipping its label.
m.scroll = 0
} else if contextTop := max(0, cursorLine-2); contextTop < m.scroll {
// Keep the active field label and its rail visible above the cursor.
m.scroll = contextTop
} else if cursorLine >= m.scroll+height {
m.scroll = cursorLine - height + 1
}
m.scroll = clamp(m.scroll, 0, max(0, len(lines)-height))
}
func (m App) prEditConfirmationLines(width int) []string {
update := m.prEditMetadata()
lines := []string{titleStyle.Render("Update this pull request?"), ""}
if update.Title != m.prEditOriginal.Title {
lines = append(lines,
dimStyle.Render("title"),
ansi.Truncate(m.prEditOriginal.Title, width, "…"),
"→ "+ansi.Truncate(update.Title, max(1, width-2), "…"),
"",
)
}
if update.BaseRef != m.prEditOriginal.BaseRef {
lines = append(lines,
dimStyle.Render("target branch"),
m.prEditOriginal.BaseRef+" → "+update.BaseRef,
"",
)
}
if update.Body != m.prEditOriginal.Body {
lines = append(lines, fmt.Sprintf(
"description changed • %d → %d characters",
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
), "")
}
lines = append(lines, warnStyle.Render(fmt.Sprintf(
"%s submit • %s continue editing",
primaryKeyLabel(m.keybindings.General.Confirm),
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
)))
return lines
}

84
system_clipboard.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"bytes"
"fmt"
"os/exec"
"runtime"
)
type textClipboard interface {
ReadText() (string, error)
WriteText(string) error
}
type systemTextClipboard struct{}
func (systemTextClipboard) ReadText() (string, error) {
command, args, err := clipboardCommand(false)
if err != nil {
return "", err
}
output, err := exec.Command(command, args...).Output()
if err != nil {
return "", fmt.Errorf("read system clipboard: %w", err)
}
return string(output), nil
}
func (systemTextClipboard) WriteText(value string) error {
command, args, err := clipboardCommand(true)
if err != nil {
return err
}
process := exec.Command(command, args...)
process.Stdin = bytes.NewBufferString(value)
if output, err := process.CombinedOutput(); err != nil {
if len(output) > 0 {
return fmt.Errorf("write system clipboard: %w: %s", err, bytes.TrimSpace(output))
}
return fmt.Errorf("write system clipboard: %w", err)
}
return nil
}
func clipboardCommand(write bool) (string, []string, error) {
switch runtime.GOOS {
case "darwin":
if write {
return "pbcopy", nil, nil
}
return "pbpaste", nil, nil
case "windows":
script := "Get-Clipboard -Raw"
if write {
script = "$input | Set-Clipboard"
}
return "powershell.exe", []string{"-NoProfile", "-NonInteractive", "-Command", script}, nil
default:
type candidate struct {
command string
write []string
read []string
}
candidates := []candidate{
{command: "wl-copy", read: []string{"-n"}, write: nil},
{command: "xclip", read: []string{"-selection", "clipboard", "-o"}, write: []string{"-selection", "clipboard", "-i"}},
{command: "xsel", read: []string{"--clipboard", "--output"}, write: []string{"--clipboard", "--input"}},
}
for _, candidate := range candidates {
command := candidate.command
args := candidate.read
if candidate.command == "wl-copy" && !write {
command = "wl-paste"
}
if write {
args = candidate.write
}
if _, err := exec.LookPath(command); err == nil {
return command, args, nil
}
}
return "", nil, fmt.Errorf("system clipboard unavailable: install wl-clipboard, xclip, or xsel")
}
}

86
terminal_cursor.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"bytes"
"io"
"os"
"sync"
"github.com/charmbracelet/x/ansi"
)
// terminalCursorOutput decorates Bubble Tea's completed frame writes with a
// hardware cursor position. Bubble Tea otherwise parks the cursor at the
// bottom of every frame, which prevents a real insertion caret inside a custom
// editor.
type terminalCursorOutput struct {
file *os.File
mu sync.Mutex
visible bool
column int
row int
}
func newTerminalCursorOutput(file *os.File) *terminalCursorOutput {
return &terminalCursorOutput{file: file}
}
func (o *terminalCursorOutput) SetCursor(visible bool, column, row int) {
o.mu.Lock()
defer o.mu.Unlock()
o.visible, o.column, o.row = visible, column, row
}
func (o *terminalCursorOutput) FrameMarker() string {
o.mu.Lock()
defer o.mu.Unlock()
if !o.visible {
return ""
}
// This zero-width sequence makes frames at different insertion positions
// distinct, preventing Bubble Tea from skipping a hardware-cursor-only
// update. The output wrapper reasserts the same position after Bubble Tea
// parks its cursor at the bottom of the frame.
return ansi.CursorPosition(o.column, o.row)
}
func (o *terminalCursorOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
written, err := o.file.Write(value)
if err != nil || written != len(value) {
return written, err
}
// Let Bubble Tea restore the cursor normally during startup/shutdown.
if bytes.Equal(value, []byte(ansi.ShowCursor)) || bytes.Equal(value, []byte(ansi.HideCursor)) {
if bytes.Equal(value, []byte(ansi.ShowCursor)) {
_, _ = io.WriteString(o.file, ansi.SetCursorStyle(0))
}
return written, nil
}
if !o.visible {
_, err = io.WriteString(o.file, ansi.HideCursor)
return written, err
}
_, err = io.WriteString(
o.file,
ansi.SetCursorStyle(5)+
ansi.CursorPosition(o.column, o.row)+
ansi.ShowCursor,
)
return written, err
}
func (o *terminalCursorOutput) Read(value []byte) (int, error) {
return o.file.Read(value)
}
func (o *terminalCursorOutput) Close() error {
return nil
}
func (o *terminalCursorOutput) Fd() uintptr {
return o.file.Fd()
}

52
terminal_cursor_test.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"os"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestTerminalCursorOutputPositionsHardwareBarAfterFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
output := newTerminalCursorOutput(file)
output.SetCursor(true, 7, 4)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
wantSuffix := ansi.SetCursorStyle(5) + ansi.CursorPosition(7, 4) + ansi.ShowCursor
if !strings.HasSuffix(string(content), wantSuffix) {
t.Fatalf("cursor output = %q, want suffix %q", content, wantSuffix)
}
}
func TestTerminalCursorOutputHidesCursorOutsideInsertMode(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
output := newTerminalCursorOutput(file)
output.SetCursor(false, 0, 0)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(string(content), ansi.HideCursor) {
t.Fatalf("cursor output did not hide cursor: %q", content)
}
}

1016
text_editor.go Normal file

File diff suppressed because it is too large Load Diff

563
text_editor_test.go Normal file
View File

@@ -0,0 +1,563 @@
package main
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
type memoryTextClipboard struct {
text string
written string
readErr error
writeErr error
}
func TestVimEditorUsesSharedConfiguredNavigation(t *testing.T) {
editor := newTextEditor("first\nsecond", true)
editor.keys.Navigation.Down = []string{"ctrl+j"}
editor.keys.Navigation.Up = []string{"ctrl+k"}
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 0 {
t.Fatalf("removed default j moved cursor to %d", editor.Cursor)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyCtrlJ}, true)
if editor.Cursor != len([]rune("first\n")) {
t.Fatalf("configured ctrl+j moved cursor to %d", editor.Cursor)
}
}
func (c *memoryTextClipboard) ReadText() (string, error) {
return c.text, c.readErr
}
func (c *memoryTextClipboard) WriteText(value string) error {
c.written = value
return c.writeErr
}
func TestVimTextEditorWordMotions(t *testing.T) {
const value = "one,two THREE four\nlast"
editor := newTextEditor(value, true)
editor.handleKey(runeKey("e"), true)
if editor.Cursor != 2 {
t.Fatalf("e cursor = %d, want 2", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("E"), true)
if editor.Cursor != 6 {
t.Fatalf("E cursor = %d, want 6", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("w"), true)
if editor.Cursor != 3 {
t.Fatalf("w cursor = %d, want punctuation at 3", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("W"), true)
if editor.Cursor != 9 {
t.Fatalf("W cursor = %d, want 9", editor.Cursor)
}
editor.Cursor = 18
editor.handleKey(runeKey("b"), true)
if editor.Cursor != 15 {
t.Fatalf("b cursor = %d, want 15", editor.Cursor)
}
editor.Cursor = 18
editor.handleKey(runeKey("B"), true)
if editor.Cursor != 15 {
t.Fatalf("B cursor = %d, want 15", editor.Cursor)
}
}
func TestVimTextEditorWordEndMotionsRepeat(t *testing.T) {
const value = "one,two THREE four\nlast"
editor := newTextEditor(value, true)
for index, want := range []int{2, 3, 6, 13, 18, 23} {
editor.handleKey(runeKey("e"), true)
if editor.Cursor != want {
t.Fatalf("e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
editor.Cursor = 0
for index, want := range []int{6, 13, 18, 23} {
editor.handleKey(runeKey("E"), true)
if editor.Cursor != want {
t.Fatalf("E repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
}
func TestVimTextEditorWordEndRepeatsAcrossSoftWraps(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
for index, want := range []int{9, 19, 21} {
editor.handleKeyAtWidth(runeKey("e"), true, 10)
if editor.Cursor != want {
t.Fatalf("soft-wrap e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
}
func TestVimTextEditorFindAndRepeat(t *testing.T) {
editor := newTextEditor("foo bar foo", true)
editor.handleKey(runeKey("f"), true)
editor.handleKey(runeKey("o"), true)
if editor.Cursor != 1 {
t.Fatalf("fo cursor = %d, want 1", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 2 {
t.Fatalf("first ; cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 9 {
t.Fatalf("second ; cursor = %d, want 9", editor.Cursor)
}
editor.handleKey(runeKey(","), true)
if editor.Cursor != 2 {
t.Fatalf(", cursor = %d, want 2", editor.Cursor)
}
}
func TestVimTextEditorTillRepeatAdvancesPastPreviousTarget(t *testing.T) {
editor := newTextEditor("xxa-b-c-d", true)
editor.handleKey(runeKey("t"), true)
editor.handleKey(runeKey("-"), true)
if editor.Cursor != 2 {
t.Fatalf("t- cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 4 {
t.Fatalf("; cursor = %d, want 4", editor.Cursor)
}
}
func TestVimTextEditorSwitchesModesAndInserts(t *testing.T) {
editor := newTextEditor("task", true)
if editor.Mode != textEditorNormal {
t.Fatalf("initial mode = %s", editor.Mode)
}
editor.handleKey(runeKey("i"), true)
editor.handleKey(runeKey("x"), true)
if editor.Text != "xtask" || editor.Mode != textEditorInsert {
t.Fatalf("insert result = %q mode=%s", editor.Text, editor.Mode)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
if editor.Mode != textEditorNormal {
t.Fatalf("escape mode = %s", editor.Mode)
}
if editor.Cursor != 0 {
t.Fatalf("escape cursor = %d, want 0", editor.Cursor)
}
}
func TestVimTextEditorSubstituteDeletesCharacterAndEntersInsert(t *testing.T) {
editor := newTextEditor("abc", true)
editor.Cursor = 1
editor.handleKey(runeKey("s"), true)
if editor.Text != "ac" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
t.Fatalf("substitute result = %#v", editor)
}
editor.handleKey(runeKey("X"), true)
if editor.Text != "aXc" {
t.Fatalf("substitute insertion result = %q", editor.Text)
}
}
func TestEditorMotionsAndDeletionPreserveGraphemeClusters(t *testing.T) {
tests := []struct {
name string
cluster string
}{
{name: "combining mark", cluster: "e\u0301"},
{name: "emoji with variation selector", cluster: "❤️"},
{name: "multi-code-point emoji", cluster: "👨‍👩‍👧‍👦"},
{name: "full-width character", cluster: "界"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
editor := newTextEditor(test.cluster+"x", true)
editor.handleKey(runeKey("l"), true)
want := len([]rune(test.cluster))
if editor.Cursor != want {
t.Fatalf("cursor = %d, want grapheme boundary %d", editor.Cursor, want)
}
editor.handleKey(runeKey("h"), true)
if editor.Cursor != 0 {
t.Fatalf("reverse cursor = %d, want 0", editor.Cursor)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "x" || editor.Cursor != 0 {
t.Fatalf("delete split grapheme: text=%q cursor=%d", editor.Text, editor.Cursor)
}
})
}
}
func TestEditorVisualYankIncludesWholeGrapheme(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("e\u0301x", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("y"), true)
if clipboard.written != "e\u0301" {
t.Fatalf("yanked text = %q, want complete combining grapheme", clipboard.written)
}
}
func TestVimTextEditorNormalMotionsStayOnCharactersWithinLine(t *testing.T) {
editor := newTextEditor("ab\n cd\n", true)
editor.Cursor = 1
editor.handleKey(runeKey("l"), true)
if editor.Cursor != 1 {
t.Fatalf("l crossed line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x result = %q", editor.Text)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x deleted newline: %q", editor.Text)
}
editor.Cursor = 4
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 7 {
t.Fatalf("j cursor = %d, want empty last line at 7", editor.Cursor)
}
editor.handleKey(runeKey("h"), true)
if editor.Cursor != 7 {
t.Fatalf("h crossed from empty line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("X"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("X deleted newline: %q", editor.Text)
}
}
func TestVimTextEditorDocumentMotionsUseFirstNonBlank(t *testing.T) {
editor := newTextEditor(" first\n last", true)
editor.Cursor = 10
editor.handleKey(runeKey("g"), true)
editor.handleKey(runeKey("g"), true)
if editor.Cursor != 2 {
t.Fatalf("gg cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey("G"), true)
if editor.Cursor != 11 {
t.Fatalf("G cursor = %d, want 11", editor.Cursor)
}
}
func TestEditorHighlightsCurrentLineWithoutChangingLayout(t *testing.T) {
editor := newTextEditor("short\nsecond", true)
lines := renderTextEditor(editor, 20, true)
if len(lines) != 2 || strings.Contains(joinEditorLines(lines), "█") {
t.Fatalf("cursor changed editor layout: %#v", lines)
}
if width := ansi.StringWidth(lines[0].text); width != 20 {
t.Fatalf("active line width = %d, want 20", width)
}
if width := ansi.StringWidth(lines[1].text); width != len("second") {
t.Fatalf("inactive line width = %d", width)
}
if !lines[0].active || lines[1].active {
t.Fatalf("active rows = %#v", lines)
}
editor.handleKey(runeKey("j"), true)
lines = renderTextEditor(editor, 20, true)
if width := ansi.StringWidth(lines[0].text); width != len("short") {
t.Fatalf("old line remained highlighted at width %d", width)
}
if width := ansi.StringWidth(lines[1].text); width != 20 {
t.Fatalf("new active line width = %d, want 20", width)
}
}
func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 16
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 3 {
t.Fatalf("wrapped rows = %d, want 3", len(rendered))
}
app := App{prEditField: prEditBodyField}
app.prEditEditors[prEditBodyField] = editor
rows := app.prEditFieldLines("description", prEditBodyField, 14)
if len(rows) != 4 {
t.Fatalf("field rows = %#v", rows)
}
want := []string{"│ abcdefghij", "│ klmnopqrst", "│ uv"}
for index, expected := range want {
plain := strings.TrimRight(ansi.Strip(rows[index+1]), " ")
if plain != expected {
t.Fatalf("wrapped row %d = %q, want %q", index, plain, expected)
}
if ansi.StringWidth(rows[index+1]) > 12 {
t.Fatalf("wrapped row %d is too wide: %d", index, ansi.StringWidth(rows[index+1]))
}
}
}
func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) {
const value = "abcdefghijklmnopqrstuv"
editor := newTextEditor(value, true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 12 {
t.Fatalf("first visual j cursor = %d, want 12", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("$"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("visual $ cursor = %d, want 19", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("l crossed soft wrap at cursor %d", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 21 {
t.Fatalf("second visual j cursor = %d, want 21", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("0"), true, 10)
if editor.Cursor != 20 {
t.Fatalf("visual 0 cursor = %d, want 20", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("k"), true, 10)
if editor.Cursor != 10 {
t.Fatalf("visual k cursor = %d, want 10", editor.Cursor)
}
if editor.Text != value {
t.Fatalf("visual navigation changed stored text: %q", editor.Text)
}
rendered := renderTextEditor(editor, 10, true)
activeRows := 0
for _, line := range rendered {
if line.active {
activeRows++
}
}
if activeRows != 1 {
t.Fatalf("active visual rows = %d, want 1", activeRows)
}
}
func TestEditorDoesNotAddPhantomRowAtExactSoftWrap(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrst", true)
editor.Cursor = len([]rune(editor.Text))
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 2 {
t.Fatalf("rendered rows = %d, want 2: %#v", len(rendered), rendered)
}
if !rendered[1].active {
t.Fatalf("last wrapped row is not active: %#v", rendered)
}
}
func TestEditorLineEndingNormalizationRemovesTerminalCarriageReturns(t *testing.T) {
const mixed = "first\nsecond\r\nthird\rfourth"
normalized := normalizeLineEndings(mixed)
if normalized != "first\nsecond\nthird\nfourth" {
t.Fatalf("normalized text = %q", normalized)
}
editor := newTextEditor(normalized, true)
for _, line := range renderTextEditor(editor, 80, true) {
if strings.ContainsRune(line.text, '\r') {
t.Fatalf("rendered terminal carriage return in %#v", line)
}
}
}
func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("v"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Mode != textEditorVisual || editor.Cursor != 13 {
t.Fatalf("visual selection mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("d"), true, 10)
if editor.Text != "abopqrstuv" {
t.Fatalf("visual delete result = %q", editor.Text)
}
if editor.Mode != textEditorNormal || editor.Cursor != 2 {
t.Fatalf("after visual delete mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdef", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("y"), true)
if clipboard.written != "abc" {
t.Fatalf("yanked text = %q, want abc", clipboard.written)
}
if editor.Text != "abcdef" || editor.Mode != textEditorNormal {
t.Fatalf("yank changed editor: %#v", editor)
}
clipboard.text = "XY"
editor.Cursor = 0
editor.handleKey(runeKey("p"), true)
if editor.Text != "aXYbcdef" || editor.Cursor != 2 {
t.Fatalf("paste result text=%q cursor=%d", editor.Text, editor.Cursor)
}
}
func TestVimVisualLineYankCollapsesSoftWraps(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.clipboard = clipboard
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("V"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("y"), true, 10)
if clipboard.written != "abcdefghijklmnopqrst" {
t.Fatalf("linewise soft-wrap yank = %q", clipboard.written)
}
if strings.ContainsRune(clipboard.written, '\n') {
t.Fatalf("soft-wrap yank introduced newline: %q", clipboard.written)
}
}
func TestVimVisualFindAcceptsArbitraryTarget(t *testing.T) {
editor := newTextEditor("one x two", true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("f"), true)
editor.handleKey(runeKey("x"), true)
if editor.Mode != textEditorVisual || editor.Cursor != 4 {
t.Fatalf("visual fx mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimClipboardErrorsRemainVisibleAndPreserveSelection(t *testing.T) {
clipboard := &memoryTextClipboard{writeErr: errors.New("clipboard failed")}
editor := newTextEditor("abc", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("y"), true)
if editor.err == nil || !strings.Contains(editor.err.Error(), "clipboard failed") {
t.Fatalf("clipboard error = %v", editor.err)
}
if editor.Mode != textEditorVisual || editor.Text != "abc" {
t.Fatalf("failed yank changed selection: %#v", editor)
}
}
func TestEditorRendersModeSpecificCursorsAndVisualSelection(t *testing.T) {
editor := newTextEditor("abc", true)
normal := renderTextEditor(editor, 10, true)
if !strings.Contains(normal[0].text, "\x1b[7m") || ansi.Strip(normal[0].text) != "abc " {
t.Fatalf("normal cursor rendering = %q", normal[0].text)
}
editor.handleKey(runeKey("i"), true)
insert := renderTextEditor(editor, 10, true)
if !strings.Contains(insert[0].text, "\x1b[4m") {
t.Fatalf("insert cursor rendering = %q", insert[0].text)
}
if width := ansi.StringWidth(insert[0].text); width != 10 {
t.Fatalf("insert cursor changed row width to %d", width)
}
if plain := strings.TrimRight(ansi.Strip(insert[0].text), " "); plain != "abc" {
t.Fatalf("insert cursor hid or shifted text: %q", plain)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
visual := renderTextEditor(editor, 10, true)
if editor.modeLabel() != "VISUAL" || !strings.Contains(visual[0].text, "\x1b[7m") {
t.Fatalf("visual rendering mode=%s text=%q", editor.modeLabel(), visual[0].text)
}
}
func TestHardwareInsertCursorDoesNotAlterRenderedText(t *testing.T) {
editor := newTextEditor("abc", true)
editor.hardwareCursor = true
editor.handleKey(runeKey("i"), true)
rendered := renderTextEditor(editor, 10, true)
if plain := strings.TrimRight(ansi.Strip(rendered[0].text), " "); plain != "abc" {
t.Fatalf("hardware cursor altered text: %q", plain)
}
if strings.Contains(rendered[0].text, "\x1b[4m") {
t.Fatalf("hardware cursor retained fallback underline: %q", rendered[0].text)
}
}
func TestMarkdownHighlightingPreservesTextWidthsAndCursorIndexes(t *testing.T) {
const markdown = "# Heading\nUse `code` and [link](https://example.com)."
editor := newTextEditor(markdown, true)
editor.highlightMarkdown = true
rendered := renderTextEditor(editor, 80, false)
var lines []string
for _, line := range rendered {
lines = append(lines, line.text)
}
highlighted := strings.Join(lines, "\n")
if ansi.Strip(highlighted) != markdown {
t.Fatalf("highlighting changed text:\n%q\nwant:\n%q", ansi.Strip(highlighted), markdown)
}
if !strings.Contains(highlighted, "\x1b[") {
t.Fatalf("Markdown was not highlighted: %q", highlighted)
}
for index, line := range rendered {
if ansi.StringWidth(line.text) != ansi.StringWidth(ansi.Strip(line.text)) {
t.Fatalf("highlighted line %d changed width", index)
}
}
editor.Cursor = strings.Index(markdown, "code")
editor.handleKey(runeKey("s"), true)
editor.handleKey(runeKey("C"), true)
if editor.Text != strings.Replace(markdown, "code", "Code", 1) {
t.Fatalf("highlighted edit changed wrong rune: %q", editor.Text)
}
}
func TestMarkdownHighlightTokenKinds(t *testing.T) {
const markdown = "# Heading\nText **strong** and *emphasis* with `code` and [link](target)\n<!-- comment -->\n"
styles := editorMarkdownStyles(markdown)
assertStyleAt := func(fragment string, want editorMarkdownStyle) {
t.Helper()
index := len([]rune(markdown[:strings.Index(markdown, fragment)]))
if styles[index] != want {
t.Fatalf("style for %q = %d, want %d", fragment, styles[index], want)
}
}
assertStyleAt("# Heading", editorMarkdownHeading)
assertStyleAt("**strong**", editorMarkdownStrong)
assertStyleAt("*emphasis*", editorMarkdownEmphasis)
assertStyleAt("`code`", editorMarkdownCode)
assertStyleAt("link", editorMarkdownLink)
assertStyleAt("target", editorMarkdownDestination)
assertStyleAt("<!-- comment -->", editorMarkdownComment)
}
func joinEditorLines(lines []editorRenderedLine) string {
var values []string
for _, line := range lines {
values = append(values, line.text)
}
return strings.Join(values, "")
}
func runeKey(value string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
}

View File

@@ -6,7 +6,10 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var currentThemeName = "dark"
func applyTheme(name string) error { func applyTheme(name string) error {
colorEnabled = true
switch name { switch name {
case "dark": case "dark":
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
@@ -15,6 +18,9 @@ func applyTheme(name string) error {
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#D7DAE8")).
Background(lipgloss.Color("#2C3045"))
paneInactiveColor = lipgloss.Color("#50566F") paneInactiveColor = lipgloss.Color("#50566F")
paneActiveColor = lipgloss.Color("#F0B72F") paneActiveColor = lipgloss.Color("#F0B72F")
authorPalette = []lipgloss.Color{ authorPalette = []lipgloss.Color{
@@ -36,6 +42,9 @@ func applyTheme(name string) error {
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#24292F")).
Background(lipgloss.Color("#DDE8FF"))
paneInactiveColor = lipgloss.Color("#8C959F") paneInactiveColor = lipgloss.Color("#8C959F")
paneActiveColor = lipgloss.Color("#0969DA") paneActiveColor = lipgloss.Color("#0969DA")
authorPalette = []lipgloss.Color{ authorPalette = []lipgloss.Color{
@@ -50,9 +59,40 @@ func applyTheme(name string) error {
suggestionAddBackground = "\x1b[48;5;194m" suggestionAddBackground = "\x1b[48;5;194m"
changedRemoveBackground = "\x1b[48;2;255;170;170m" changedRemoveBackground = "\x1b[48;2;255;170;170m"
changedAddBackground = "\x1b[48;2;170;230;170m" changedAddBackground = "\x1b[48;2;170;230;170m"
case "high-contrast":
titleStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#FFFF00"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
activeStyle = lipgloss.NewStyle().Bold(true).Reverse(true)
okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00"))
warnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFF00"))
badStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Reverse(true)
paneInactiveColor, paneActiveColor = lipgloss.Color("#FFFFFF"), lipgloss.Color("#FFFF00")
authorPalette = []lipgloss.Color{"#00FFFF", "#FF55FF", "#FFFF00", "#00FF00", "#FFFFFF"}
codeHighlightTheme, markdownStyleName = "github-dark", "dark"
quoteRailStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
selectedLineBackground = "\x1b[7m"
suggestionRemoveBackground, suggestionAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
changedRemoveBackground, changedAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
case "no-color":
colorEnabled = false
titleStyle = lipgloss.NewStyle()
dimStyle = lipgloss.NewStyle()
activeStyle = lipgloss.NewStyle().Reverse(true)
okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
editorLineStyle = lipgloss.NewStyle().Reverse(true)
paneInactiveColor, paneActiveColor = "", ""
authorPalette = []lipgloss.Color{""}
codeHighlightTheme, markdownStyleName = "github", "notty"
quoteRailStyle = lipgloss.NewStyle()
selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", ""
changedRemoveBackground, changedAddBackground = "", ""
default: default:
return fmt.Errorf("unknown theme %q", name) return fmt.Errorf("unknown theme %q", name)
} }
currentThemeName = name
commentMarkdownRenderers.Clear() commentMarkdownRenderers.Clear()
return nil return nil
} }

View File

@@ -1,6 +1,9 @@
package main package main
import "testing" import (
"strings"
"testing"
)
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) { func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
defer func() { defer func() {
@@ -18,3 +21,14 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
t.Fatal("unknown theme was accepted") t.Fatal("unknown theme was accepted")
} }
} }
func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("no-color"); err != nil {
t.Fatal(err)
}
rendered := highlightedSource("go", "func main() {}")
if strings.Contains(rendered, "\x1b[") || colorEnabled {
t.Fatalf("no-color source contains terminal colors: %q", rendered)
}
}

2624
tui.go

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

208
types.go
View File

@@ -15,20 +15,180 @@ type PullRequest struct {
UpdatedAt time.Time UpdatedAt time.Time
ReviewCount int ReviewCount int
ViewerAuthored bool ViewerAuthored bool
FromCache bool
CachedAt time.Time
} }
type PRDetails struct { type PRDetails struct {
PullRequest PullRequest
Body string Body string
BaseRef string CreatedAt time.Time
HeadRef string BaseRef string
Mergeable string HeadRef string
Assignees []string Mergeable string
Reviewers []Reviewer MergeState string
CheckState string State string
ReviewDecision string Merged bool
Threads []ReviewThread MergedAt time.Time
ThreadsTruncated bool AutoMerge *AutoMergeRequest
AllowedMergeMethods []string
ConflictFiles []string
ConflictFileError string
Assignees []string
Reviewers []Reviewer
Labels []string
Milestone string
Additions int
Deletions int
ChangedFiles int
CommitCount int
CommentCount int
HeadOID string
BaseOID string
RepositoryURL string
CheckState string
Checks []Check
ReviewDecision string
Conversation []PRComment
Reviews []ReviewSummary
Permissions ViewerPermissions
Requirements MergeRequirements
Threads []ReviewThread
ThreadsTruncated bool
Timeline []TimelineEvent
DataIssues []DataIssue
Rulesets []Ruleset
MergeQueue *MergeQueue
FromCache bool
CachedAt time.Time
}
type DataIssue struct {
Component string
Message string
}
type PRDetailsEnrichment struct {
Owner string
Repository string
Number int
HeadOID string
CheckAnnotations map[string][]CheckAnnotation
ConflictFiles []string
Issues []DataIssue
}
type PullRequestMetadata struct {
Title string
Body string
BaseRef string
Mergeable string
MergeState string
UpdatedAt time.Time
}
type AutoMergeRequest struct {
MergeMethod string
EnabledBy string
EnabledAt time.Time
}
type PullRequestMergeResult struct {
Merged bool
MergedAt time.Time
}
type RepositoryBranch struct {
Name string
UpdatedAt time.Time
IsDefault bool
}
type Check struct {
ID string
Name string
State string
Conclusion string
URL string
Summary string
Annotations []CheckAnnotation
}
type CheckAnnotation struct {
Path string
StartLine int
EndLine int
Level string
Title string
Message string
}
type TimelineEvent struct {
Kind string
OID string
BeforeOID string
AfterOID string
Author string
Title string
CreatedAt time.Time
}
type Ruleset struct {
Name string
Enforcement string
RuleTypes []string
Applies bool
}
type MergeQueue struct {
State string
Position int
EnqueuedAt time.Time
EstimatedSeconds int
}
type PRComment struct {
ID string
Author string
Body string
URL string
CreatedAt time.Time
}
type ReviewSummary struct {
ID string
Author string
Body string
State string
URL string
CommitOID string
SubmittedAt time.Time
}
type ViewerPermissions struct {
Repository string
CanUpdatePR bool
CanResolveAny bool
CanUnresolveAny bool
CanReplyAny bool
CanReact bool
CanSubscribe bool
CanEnableMerge bool
CanDisableMerge bool
}
type MergeRequirements struct {
ApprovalsRequired int
RequiresApprovals bool
RequiresStatusChecks bool
RequiresConversation bool
RequiresCodeOwnerReview bool
RequiresDeployments bool
RequiredDeployments []string
RequiresStrictChecks bool
RequiresLinearHistory bool
RequiresSignatures bool
RequiresMergeQueue bool
} }
type Reviewer struct { type Reviewer struct {
@@ -37,15 +197,18 @@ type Reviewer struct {
} }
type ReviewThread struct { type ReviewThread struct {
ID string ID string
Path string Path string
Line int Line int
StartLine int StartLine int
DiffSide string DiffSide string
IsResolved bool IsResolved bool
IsOutdated bool IsOutdated bool
IsTruncated bool IsTruncated bool
Comments []ReviewComment ViewerCanResolve bool
ViewerCanUnresolve bool
ViewerCanReply bool
Comments []ReviewComment
} }
type ReviewComment struct { type ReviewComment struct {
@@ -61,4 +224,11 @@ type ReviewComment struct {
Outdated bool Outdated bool
CreatedAt time.Time CreatedAt time.Time
URL string URL string
Reactions []ReactionSummary
}
type ReactionSummary struct {
Content string
Count int
ViewerHasReacted bool
} }