Compare commits
10 Commits
60d64dedb2
...
a0098a3946
| Author | SHA1 | Date | |
|---|---|---|---|
| a0098a3946 | |||
| 42dcceaf4b | |||
| 7511311297 | |||
| 948f3e1a79 | |||
| bb8e91039f | |||
| 72e1eb1fda | |||
| f93c2c517a | |||
| 0e880fa9b0 | |||
| fdaee6a69e | |||
| 43fa047765 |
295
README.md
295
README.md
@@ -1,22 +1,26 @@
|
||||
# gh-threads
|
||||
# diple
|
||||
|
||||
A read-only terminal UI for people receiving GitHub pull-request reviews. It
|
||||
shows open PRs, review threads with highlighted diff hunks and comment authors,
|
||||
reviewer/assignee state, and the latest commit's check rollup. Resolved threads
|
||||
start folded. GitHub suggestion blocks are shown as syntax-highlighted
|
||||
remove/add previews. Comments render GitHub Flavored Markdown, including quoted
|
||||
replies, inline and fenced code, lists and tasks, links, tables, emphasis,
|
||||
strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the
|
||||
background.
|
||||
A terminal UI for people receiving GitHub pull-request reviews. It
|
||||
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||
review state, merge conflicts and affected files, checks, people, labels,
|
||||
milestone, activity, change statistics, thread totals, submitted reviews, and
|
||||
the PR conversation. Review threads and comments are paginated rather than
|
||||
silently stopping at the first page. The
|
||||
thread viewer includes highlighted diff hunks, comment authors, and read-only
|
||||
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
|
||||
|
||||
Requires Go 1.24+ and an authenticated GitHub CLI:
|
||||
Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
|
||||
|
||||
```sh
|
||||
go install .
|
||||
gh auth login
|
||||
gh-threads
|
||||
diple
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
gh-threads --repo owner/repository
|
||||
diple --repo owner/repository
|
||||
```
|
||||
|
||||
With a repository selected, pass `--all` to include every open PR in that
|
||||
repository:
|
||||
|
||||
```sh
|
||||
gh-threads --repo owner/repository --all --poll 15s
|
||||
diple --repo owner/repository --all --poll 15s
|
||||
```
|
||||
|
||||
GitHub Enterprise Server can be used after authenticating that host:
|
||||
|
||||
```sh
|
||||
gh auth login --hostname github.example.com
|
||||
gh-threads --repo owner/repository \
|
||||
diple --repo owner/repository \
|
||||
--endpoint https://github.example.com/api/graphql
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The optional TOML configuration is loaded from
|
||||
`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the
|
||||
operating system's user configuration directory at `gh-threads/config.toml`.
|
||||
On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS,
|
||||
`~/Library/Application Support/gh-threads/config.toml` is preferred, with
|
||||
`~/.config/gh-threads/config.toml` automatically used as a fallback when it
|
||||
`$DIPLE_CONFIG`, `$XDG_CONFIG_HOME/diple/config.toml`, or the operating
|
||||
system's user configuration directory at `diple/config.toml`.
|
||||
On Linux this is normally `~/.config/diple/config.toml`. On macOS,
|
||||
`~/Library/Application Support/diple/config.toml` is preferred, with
|
||||
`~/.config/diple/config.toml` automatically used as a fallback when it
|
||||
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
|
||||
theme = "dark" # "dark" or "light"
|
||||
theme = "dark" # dark, light, high-contrast, or no-color
|
||||
refresh_interval = "10s"
|
||||
repository = "" # optional owner/repository default
|
||||
show_all = false # requires repository
|
||||
@@ -76,6 +84,8 @@ endpoint = "https://api.github.com/graphql"
|
||||
[display]
|
||||
fold_resolved = true
|
||||
thread_list_width_percent = 33 # 20-60
|
||||
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
|
||||
compact_reviews = true # aggregate submitted review history
|
||||
|
||||
[paths]
|
||||
scroll = false
|
||||
@@ -86,37 +96,258 @@ scroll_interval = "350ms" # minimum 50ms
|
||||
# resolved threads remain in "resolved" even when they are also outdated.
|
||||
status_order = ["unresolved", "outdated", "resolved"]
|
||||
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
|
||||
configured repository when `--repo` is not provided. The corresponding flags
|
||||
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
||||
`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean
|
||||
settings can be disabled explicitly, for example `--path-scroll=false`.
|
||||
`--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
|
||||
`--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 |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `/` | Fuzzy-search thread file paths |
|
||||
| `↑` / `↓` | Choose a fuzzy-search match |
|
||||
| `H` | Open application health and diagnostics |
|
||||
| `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 |
|
||||
| `enter` / `l` | Open a PR |
|
||||
| `enter` / `l` | Open the selected PR dashboard or its review threads |
|
||||
| `enter` | Toggle the selected review thread |
|
||||
| `za` | Toggle the selected thread |
|
||||
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
|
||||
| `tab` | Hide or reveal the thread list |
|
||||
| `b` / `esc` | Return to the PR picker |
|
||||
| `b` / `esc` | Return to the previous screen |
|
||||
| `r` | Refresh now |
|
||||
| `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
|
||||
|
||||
The application is intentionally read-only. GitHub's GraphQL API currently
|
||||
limits this client to the first 100 review threads and first 100 comments per
|
||||
thread; the UI warns when the thread list is truncated. GitHub features which
|
||||
The application can reply to review threads, resolve or unresolve them, update
|
||||
the PR title, target branch, and description, enable or disable auto-merge, and
|
||||
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
|
||||
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
126
TODO.md
Normal 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
213
branch_completion.go
Normal 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
104
branch_completion_test.go
Normal 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
464
cache.go
Normal 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
248
cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
96
config.go
96
config.go
@@ -34,11 +34,16 @@ type Config struct {
|
||||
Display DisplayConfig `toml:"display"`
|
||||
Paths PathConfig `toml:"paths"`
|
||||
Threads ThreadConfig `toml:"threads"`
|
||||
Cache CacheConfig `toml:"cache"`
|
||||
Editing EditingConfig `toml:"editing"`
|
||||
KeyBindings KeyBindings `toml:"keybindings"`
|
||||
}
|
||||
|
||||
type DisplayConfig struct {
|
||||
FoldResolved bool `toml:"fold_resolved"`
|
||||
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
||||
FoldResolved bool `toml:"fold_resolved"`
|
||||
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
||||
DashboardMode string `toml:"dashboard_mode"`
|
||||
CompactReviews bool `toml:"compact_reviews"`
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
@@ -51,6 +56,17 @@ type ThreadConfig struct {
|
||||
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 {
|
||||
return Config{
|
||||
Theme: "dark",
|
||||
@@ -60,6 +76,8 @@ func defaultConfig() Config {
|
||||
Display: DisplayConfig{
|
||||
FoldResolved: true,
|
||||
ThreadListWidthPercent: 33,
|
||||
DashboardMode: "hotkey",
|
||||
CompactReviews: true,
|
||||
},
|
||||
Paths: PathConfig{
|
||||
Scroll: false,
|
||||
@@ -69,35 +87,55 @@ func defaultConfig() Config {
|
||||
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
||||
WithinStatus: "file",
|
||||
},
|
||||
Cache: CacheConfig{
|
||||
Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}, MaxEntries: 200,
|
||||
},
|
||||
Editing: EditingConfig{Mode: "vim"},
|
||||
KeyBindings: defaultKeyBindings(),
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
return path, nil
|
||||
}
|
||||
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()
|
||||
if err != nil {
|
||||
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()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find home directory: %w", err)
|
||||
}
|
||||
fallback := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
||||
return existingConfigPath(preferred, fallback), nil
|
||||
dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
|
||||
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 {
|
||||
if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
return preferred
|
||||
}
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback
|
||||
return firstExistingOrDefault(preferred, fallback)
|
||||
}
|
||||
|
||||
func firstExistingOrDefault(preferred string, alternatives ...string) string {
|
||||
for _, candidate := range append([]string{preferred}, alternatives...) {
|
||||
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
@@ -123,15 +161,15 @@ func loadConfig(path string, required bool) (Config, error) {
|
||||
|
||||
func validateConfig(config Config) error {
|
||||
switch config.Theme {
|
||||
case "dark", "light":
|
||||
case "dark", "light", "no-color", "high-contrast":
|
||||
default:
|
||||
return fmt.Errorf("theme must be dark or light")
|
||||
}
|
||||
if config.RefreshInterval.Duration < 2*time.Second {
|
||||
return fmt.Errorf("refresh_interval must be at least 2s")
|
||||
}
|
||||
if config.Limit < 1 || config.Limit > 100 {
|
||||
return fmt.Errorf("limit must be between 1 and 100")
|
||||
if config.Limit < 1 || config.Limit > 1000 {
|
||||
return fmt.Errorf("limit must be between 1 and 1000")
|
||||
}
|
||||
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -150,9 +193,34 @@ func validateConfig(config Config) error {
|
||||
if config.ShowAll && config.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
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(order) != 3 {
|
||||
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")
|
||||
|
||||
131
config_test.go
131
config_test.go
@@ -18,7 +18,9 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
|
||||
if got.Theme != want.Theme ||
|
||||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +38,8 @@ endpoint = "https://github.example.com/api/graphql"
|
||||
[display]
|
||||
fold_resolved = false
|
||||
thread_list_width_percent = 45
|
||||
dashboard_mode = "hotkey"
|
||||
compact_reviews = false
|
||||
|
||||
[paths]
|
||||
scroll = true
|
||||
@@ -44,6 +48,18 @@ scroll_interval = "125ms"
|
||||
[threads]
|
||||
status_order = ["resolved", "unresolved", "outdated"]
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
@@ -58,13 +74,69 @@ within_status = "timestamp"
|
||||
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
|
||||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
||||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
||||
got.Display.DashboardMode != "hotkey" ||
|
||||
got.Display.CompactReviews ||
|
||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
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) {
|
||||
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()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "/tmp/custom-gh-threads.toml" {
|
||||
if got != "/tmp/custom-diple.toml" {
|
||||
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) {
|
||||
root := t.TempDir()
|
||||
preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml")
|
||||
fallback := filepath.Join(root, ".config", "gh-threads", "config.toml")
|
||||
preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml")
|
||||
fallback := filepath.Join(root, ".config", "diple", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
|
||||
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) {
|
||||
t.Setenv("GH_THREADS_CONFIG", "")
|
||||
t.Setenv("DIPLE_CONFIG", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
||||
got, err := configPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "/tmp/xdg-config/gh-threads/config.toml"
|
||||
want := "/tmp/xdg-config/diple/config.toml"
|
||||
if 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")
|
||||
}
|
||||
}
|
||||
|
||||
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
190
conflicts.go
Normal 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
86
conflicts_test.go
Normal 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
208
drafts.go
Normal 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)
|
||||
}
|
||||
511
github_test.go
511
github_test.go
@@ -5,10 +5,95 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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) {
|
||||
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,
|
||||
"updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED",
|
||||
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"},
|
||||
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
||||
"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"}]},
|
||||
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
|
||||
"milestone":{"title":"v2"},
|
||||
"comments":{"totalCount":5},
|
||||
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
|
||||
"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":[{
|
||||
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
|
||||
"viewerCanResolve":true,
|
||||
"line":null,"originalLine":42,"diffSide":"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",
|
||||
"url":"cu","author":{"login":"reviewer"},"outdated":true,
|
||||
"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" {
|
||||
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 ||
|
||||
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)
|
||||
}
|
||||
comment := got.Threads[0].Comments[0]
|
||||
@@ -127,4 +572,56 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||
comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
|
||||
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
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module git.pablu.de/Pablu/gh-threads
|
||||
module git.pablu.de/Pablu/diple
|
||||
|
||||
go 1.24.0
|
||||
|
||||
|
||||
157
health.go
Normal file
157
health.go
Normal 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
201
health_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
highlight.go
32
highlight.go
@@ -14,6 +14,7 @@ import (
|
||||
const reviewContextLines = 3
|
||||
|
||||
var codeHighlightTheme = "github-dark"
|
||||
var colorEnabled = true
|
||||
|
||||
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))
|
||||
for _, line := range visible {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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 = trimIndent(source, padding)
|
||||
return highlightedDiffLine{
|
||||
gutter: fmt.Sprintf(
|
||||
gutter := fmt.Sprintf("%5s %s ", lineNumber, marker)
|
||||
if colorEnabled {
|
||||
gutter = fmt.Sprintf(
|
||||
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
|
||||
lineNumber, markerStyle, marker,
|
||||
),
|
||||
)
|
||||
}
|
||||
return highlightedDiffLine{
|
||||
gutter: gutter,
|
||||
code: highlightedSource(lexer, source),
|
||||
selected: line.selected,
|
||||
}
|
||||
@@ -224,6 +241,9 @@ func coordinateText(line int) string {
|
||||
}
|
||||
|
||||
func highlightedSource(lexer, source string) string {
|
||||
if !colorEnabled {
|
||||
return source
|
||||
}
|
||||
var highlighted bytes.Buffer
|
||||
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
|
||||
return source
|
||||
|
||||
698
keybindings.go
Normal file
698
keybindings.go
Normal 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
74
main.go
@@ -4,6 +4,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
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)")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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()
|
||||
|
||||
visited := map[string]bool{}
|
||||
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 {
|
||||
exitf("configuration: %v", err)
|
||||
}
|
||||
@@ -62,12 +73,30 @@ func main() {
|
||||
if visited["thread-list-width"] {
|
||||
config.Display.ThreadListWidthPercent = *listWidth
|
||||
}
|
||||
if visited["dashboard-mode"] {
|
||||
config.Display.DashboardMode = *dashboardMode
|
||||
}
|
||||
if visited["compact-reviews"] {
|
||||
config.Display.CompactReviews = *compactReviews
|
||||
}
|
||||
if visited["path-scroll"] {
|
||||
config.Paths.Scroll = *pathScroll
|
||||
}
|
||||
if visited["path-scroll-interval"] {
|
||||
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 {
|
||||
exitf("configuration: %v", err)
|
||||
}
|
||||
@@ -89,18 +118,45 @@ func main() {
|
||||
}
|
||||
|
||||
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(
|
||||
client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
||||
service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
||||
AppSettings{
|
||||
FoldResolved: config.Display.FoldResolved,
|
||||
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
||||
DashboardMode: config.Display.DashboardMode,
|
||||
CompactReviews: config.Display.CompactReviews,
|
||||
ReadState: loadReadState(statePath),
|
||||
Drafts: loadDraftStore(draftPath),
|
||||
PathScroll: config.Paths.Scroll,
|
||||
PathScrollInterval: config.Paths.ScrollInterval.Duration,
|
||||
ThreadStatusOrder: config.Threads.StatusOrder,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -115,9 +171,15 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Keep interface drift visible at compile time.
|
||||
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)
|
||||
|
||||
@@ -82,6 +82,8 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
|
||||
style := styles.DarkStyleConfig
|
||||
if markdownStyleName == "light" {
|
||||
style = styles.LightStyleConfig
|
||||
} else if markdownStyleName == "notty" {
|
||||
style = styles.NoTTYStyleConfig
|
||||
}
|
||||
zero := uint(0)
|
||||
style.Document.Margin = &zero
|
||||
|
||||
182
markdown_editor_highlight.go
Normal file
182
markdown_editor_highlight.go
Normal 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
53
persistence.go
Normal 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
478
pr_editor.go
Normal 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
84
system_clipboard.go
Normal 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
86
terminal_cursor.go
Normal 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
52
terminal_cursor_test.go
Normal 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
1016
text_editor.go
Normal file
File diff suppressed because it is too large
Load Diff
563
text_editor_test.go
Normal file
563
text_editor_test.go
Normal 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)}
|
||||
}
|
||||
40
theme.go
40
theme.go
@@ -6,7 +6,10 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var currentThemeName = "dark"
|
||||
|
||||
func applyTheme(name string) error {
|
||||
colorEnabled = true
|
||||
switch name {
|
||||
case "dark":
|
||||
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"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
|
||||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
|
||||
editorLineStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#D7DAE8")).
|
||||
Background(lipgloss.Color("#2C3045"))
|
||||
paneInactiveColor = lipgloss.Color("#50566F")
|
||||
paneActiveColor = lipgloss.Color("#F0B72F")
|
||||
authorPalette = []lipgloss.Color{
|
||||
@@ -36,6 +42,9 @@ func applyTheme(name string) error {
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
|
||||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
|
||||
editorLineStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#24292F")).
|
||||
Background(lipgloss.Color("#DDE8FF"))
|
||||
paneInactiveColor = lipgloss.Color("#8C959F")
|
||||
paneActiveColor = lipgloss.Color("#0969DA")
|
||||
authorPalette = []lipgloss.Color{
|
||||
@@ -50,9 +59,40 @@ func applyTheme(name string) error {
|
||||
suggestionAddBackground = "\x1b[48;5;194m"
|
||||
changedRemoveBackground = "\x1b[48;2;255;170;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:
|
||||
return fmt.Errorf("unknown theme %q", name)
|
||||
}
|
||||
currentThemeName = name
|
||||
commentMarkdownRenderers.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
||||
defer func() {
|
||||
@@ -18,3 +21,14 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
1297
tui_test.go
1297
tui_test.go
File diff suppressed because it is too large
Load Diff
208
types.go
208
types.go
@@ -15,20 +15,180 @@ type PullRequest struct {
|
||||
UpdatedAt time.Time
|
||||
ReviewCount int
|
||||
ViewerAuthored bool
|
||||
FromCache bool
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
type PRDetails struct {
|
||||
PullRequest
|
||||
Body string
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
Mergeable string
|
||||
Assignees []string
|
||||
Reviewers []Reviewer
|
||||
CheckState string
|
||||
ReviewDecision string
|
||||
Threads []ReviewThread
|
||||
ThreadsTruncated bool
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
Mergeable string
|
||||
MergeState string
|
||||
State string
|
||||
Merged bool
|
||||
MergedAt time.Time
|
||||
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 {
|
||||
@@ -37,15 +197,18 @@ type Reviewer struct {
|
||||
}
|
||||
|
||||
type ReviewThread struct {
|
||||
ID string
|
||||
Path string
|
||||
Line int
|
||||
StartLine int
|
||||
DiffSide string
|
||||
IsResolved bool
|
||||
IsOutdated bool
|
||||
IsTruncated bool
|
||||
Comments []ReviewComment
|
||||
ID string
|
||||
Path string
|
||||
Line int
|
||||
StartLine int
|
||||
DiffSide string
|
||||
IsResolved bool
|
||||
IsOutdated bool
|
||||
IsTruncated bool
|
||||
ViewerCanResolve bool
|
||||
ViewerCanUnresolve bool
|
||||
ViewerCanReply bool
|
||||
Comments []ReviewComment
|
||||
}
|
||||
|
||||
type ReviewComment struct {
|
||||
@@ -61,4 +224,11 @@ type ReviewComment struct {
|
||||
Outdated bool
|
||||
CreatedAt time.Time
|
||||
URL string
|
||||
Reactions []ReactionSummary
|
||||
}
|
||||
|
||||
type ReactionSummary struct {
|
||||
Content string
|
||||
Count int
|
||||
ViewerHasReacted bool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user