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
|
A terminal UI for people receiving GitHub pull-request reviews. It
|
||||||
shows open PRs, review threads with highlighted diff hunks and comment authors,
|
shows open PRs and a scrollable PR dashboard with the description, branches,
|
||||||
reviewer/assignee state, and the latest commit's check rollup. Resolved threads
|
review state, merge conflicts and affected files, checks, people, labels,
|
||||||
start folded. GitHub suggestion blocks are shown as syntax-highlighted
|
milestone, activity, change statistics, thread totals, submitted reviews, and
|
||||||
remove/add previews. Comments render GitHub Flavored Markdown, including quoted
|
the PR conversation. Review threads and comments are paginated rather than
|
||||||
replies, inline and fenced code, lists and tasks, links, tables, emphasis,
|
silently stopping at the first page. The
|
||||||
strikethrough, emoji, and GitHub alerts. The current PR is refreshed in the
|
thread viewer includes highlighted diff hunks, comment authors, and read-only
|
||||||
background.
|
reaction counts on individual comments. Resolved threads start folded. GitHub suggestion blocks are shown as
|
||||||
|
syntax-highlighted remove/add previews. Comments and PR descriptions render
|
||||||
|
GitHub Flavored Markdown, including quoted replies, inline and fenced code,
|
||||||
|
lists and tasks, links, tables, emphasis, strikethrough, emoji, and GitHub
|
||||||
|
alerts. The current PR is refreshed in the background.
|
||||||
|
|
||||||
## Install and run
|
## Install and run
|
||||||
|
|
||||||
Requires Go 1.24+ and an authenticated GitHub CLI:
|
Requires Go 1.24+, Git 2.38+, and an authenticated GitHub CLI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install .
|
go install .
|
||||||
gh auth login
|
gh auth login
|
||||||
gh-threads
|
diple
|
||||||
```
|
```
|
||||||
|
|
||||||
To run directly from a source checkout instead:
|
To run directly from a source checkout instead:
|
||||||
@@ -37,36 +41,40 @@ authenticated user and groups the results by repository. Use `--repo` to limit
|
|||||||
the picker to one repository:
|
the picker to one repository:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh-threads --repo owner/repository
|
diple --repo owner/repository
|
||||||
```
|
```
|
||||||
|
|
||||||
With a repository selected, pass `--all` to include every open PR in that
|
With a repository selected, pass `--all` to include every open PR in that
|
||||||
repository:
|
repository:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh-threads --repo owner/repository --all --poll 15s
|
diple --repo owner/repository --all --poll 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Enterprise Server can be used after authenticating that host:
|
GitHub Enterprise Server can be used after authenticating that host:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh auth login --hostname github.example.com
|
gh auth login --hostname github.example.com
|
||||||
gh-threads --repo owner/repository \
|
diple --repo owner/repository \
|
||||||
--endpoint https://github.example.com/api/graphql
|
--endpoint https://github.example.com/api/graphql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The optional TOML configuration is loaded from
|
The optional TOML configuration is loaded from
|
||||||
`$GH_THREADS_CONFIG`, `$XDG_CONFIG_HOME/gh-threads/config.toml`, or the
|
`$DIPLE_CONFIG`, `$XDG_CONFIG_HOME/diple/config.toml`, or the operating
|
||||||
operating system's user configuration directory at `gh-threads/config.toml`.
|
system's user configuration directory at `diple/config.toml`.
|
||||||
On Linux this is normally `~/.config/gh-threads/config.toml`. On macOS,
|
On Linux this is normally `~/.config/diple/config.toml`. On macOS,
|
||||||
`~/Library/Application Support/gh-threads/config.toml` is preferred, with
|
`~/Library/Application Support/diple/config.toml` is preferred, with
|
||||||
`~/.config/gh-threads/config.toml` automatically used as a fallback when it
|
`~/.config/diple/config.toml` automatically used as a fallback when it
|
||||||
exists.
|
exists.
|
||||||
|
|
||||||
|
For migration, `GH_THREADS_CONFIG` and existing `gh-threads` configuration or
|
||||||
|
cache directories remain fallback locations when their new `diple`
|
||||||
|
counterparts do not yet exist.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
theme = "dark" # "dark" or "light"
|
theme = "dark" # dark, light, high-contrast, or no-color
|
||||||
refresh_interval = "10s"
|
refresh_interval = "10s"
|
||||||
repository = "" # optional owner/repository default
|
repository = "" # optional owner/repository default
|
||||||
show_all = false # requires repository
|
show_all = false # requires repository
|
||||||
@@ -76,6 +84,8 @@ endpoint = "https://api.github.com/graphql"
|
|||||||
[display]
|
[display]
|
||||||
fold_resolved = true
|
fold_resolved = true
|
||||||
thread_list_width_percent = 33 # 20-60
|
thread_list_width_percent = 33 # 20-60
|
||||||
|
dashboard_mode = "hotkey" # "hotkey" or "intermediate"
|
||||||
|
compact_reviews = true # aggregate submitted review history
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
scroll = false
|
scroll = false
|
||||||
@@ -86,37 +96,258 @@ scroll_interval = "350ms" # minimum 50ms
|
|||||||
# resolved threads remain in "resolved" even when they are also outdated.
|
# resolved threads remain in "resolved" even when they are also outdated.
|
||||||
status_order = ["unresolved", "outdated", "resolved"]
|
status_order = ["unresolved", "outdated", "resolved"]
|
||||||
within_status = "file" # "file" or "timestamp" (oldest first)
|
within_status = "file" # "file" or "timestamp" (oldest first)
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
enabled = true # instant stale view plus offline fallback
|
||||||
|
max_age = "168h" # 7 days; 0 means no age limit
|
||||||
|
directory = "" # defaults to the OS user cache directory
|
||||||
|
max_entries = 200 # bounded oldest-first pruning; 10-10000
|
||||||
|
|
||||||
|
[editing]
|
||||||
|
mode = "vim" # "vim" or "standard"; description field only for now
|
||||||
|
|
||||||
|
[keybindings.general]
|
||||||
|
quit = ["q", "ctrl+c"]
|
||||||
|
help = ["?", "f1"]
|
||||||
|
refresh = ["r"]
|
||||||
|
back = ["b", "esc"]
|
||||||
|
confirm = ["y"]
|
||||||
|
reject = ["n", "esc"]
|
||||||
|
|
||||||
|
[keybindings.navigation]
|
||||||
|
# Shared by the picker, dashboard, thread panes, help, and Vim Normal/Visual modes.
|
||||||
|
down = ["j", "down"]
|
||||||
|
up = ["k", "up"]
|
||||||
|
left = ["h", "left"]
|
||||||
|
right = ["l", "right"]
|
||||||
|
first = ["g"]
|
||||||
|
last = ["G"]
|
||||||
|
page_down = ["ctrl+d", "pgdown"]
|
||||||
|
page_up = ["ctrl+u", "pgup"]
|
||||||
|
|
||||||
|
[keybindings.views]
|
||||||
|
open = ["enter", "l"]
|
||||||
|
dashboard = ["d"]
|
||||||
|
health = ["H"]
|
||||||
|
edit = ["e"]
|
||||||
|
auto_merge = ["a"]
|
||||||
|
merge_now = ["M"]
|
||||||
|
toggle_list = ["tab"]
|
||||||
|
|
||||||
|
[keybindings.threads]
|
||||||
|
search = ["/"]
|
||||||
|
clear_filter = ["F"]
|
||||||
|
next_unread = ["n"]
|
||||||
|
previous_unread = ["N"]
|
||||||
|
reply = ["c"]
|
||||||
|
resolve = ["R"]
|
||||||
|
toggle = ["enter"]
|
||||||
|
fold_prefix = ["z"]
|
||||||
|
fold_toggle = ["a"]
|
||||||
|
|
||||||
|
[keybindings.input]
|
||||||
|
cancel = ["esc"]
|
||||||
|
submit = ["ctrl+s"]
|
||||||
|
newline = ["enter"]
|
||||||
|
delete_backward = ["backspace"]
|
||||||
|
delete_forward = ["delete"]
|
||||||
|
clear = ["ctrl+u"]
|
||||||
|
next_field = ["tab"]
|
||||||
|
previous_field = ["shift+tab"]
|
||||||
|
next_completion = ["ctrl+n"]
|
||||||
|
previous_completion = ["ctrl+p"]
|
||||||
|
line_start = ["home", "ctrl+a"]
|
||||||
|
line_end = ["end", "ctrl+e"]
|
||||||
|
|
||||||
|
[keybindings.vim]
|
||||||
|
insert = ["i"]
|
||||||
|
append = ["a"]
|
||||||
|
insert_line_start = ["I"]
|
||||||
|
append_line_end = ["A"]
|
||||||
|
open_below = ["o"]
|
||||||
|
open_above = ["O"]
|
||||||
|
replace_character = ["s"]
|
||||||
|
visual = ["v"]
|
||||||
|
visual_line = ["V"]
|
||||||
|
selection_other_end = ["o"]
|
||||||
|
yank = ["y"]
|
||||||
|
delete = ["d", "x", "delete"]
|
||||||
|
delete_before = ["X", "backspace"]
|
||||||
|
paste = ["p"]
|
||||||
|
line_start = ["0", "home"]
|
||||||
|
first_non_blank = ["^"]
|
||||||
|
line_end = ["$", "end"]
|
||||||
|
word_forward = ["w"]
|
||||||
|
big_word_forward = ["W"]
|
||||||
|
word_backward = ["b"]
|
||||||
|
big_word_backward = ["B"]
|
||||||
|
word_end = ["e"]
|
||||||
|
big_word_end = ["E"]
|
||||||
|
go_prefix = ["g"]
|
||||||
|
find_forward = ["f"]
|
||||||
|
find_backward = ["F"]
|
||||||
|
till_forward = ["t"]
|
||||||
|
till_backward = ["T"]
|
||||||
|
repeat_find = [";"]
|
||||||
|
repeat_find_reverse = [","]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every command binding accepts one or more Bubble Tea key names. Omitted
|
||||||
|
settings retain their defaults, while an explicitly configured action replaces
|
||||||
|
its default keys. Printable keys remain text in Insert mode, reply drafts, and
|
||||||
|
search queries; command bindings apply in the appropriate non-text context.
|
||||||
|
The contextual `?` popup and compact screen footers use the configured keys.
|
||||||
|
Configuration loading also checks each active context independently. A key may
|
||||||
|
be reused on unrelated screens, but assigning it to two different actions that
|
||||||
|
can be active together reports the context and both conflicting actions.
|
||||||
|
|
||||||
|
When cached data exists, the picker and PR details are rendered immediately
|
||||||
|
from that snapshot while a live GitHub refresh runs in the background. Cached
|
||||||
|
screens are labelled with their save time and are replaced automatically when
|
||||||
|
fresh data arrives. Core PR and review data is rendered before check
|
||||||
|
annotations and conflict-file analysis finish. A failed subsection keeps its
|
||||||
|
last complete value, is marked partial, and does not discard the rest of a
|
||||||
|
successful refresh. Check annotations are fetched separately only for failed
|
||||||
|
checks and are reused by immutable check ID.
|
||||||
|
|
||||||
|
The cache uses separate JSON files for the picker and each visited PR. Cache
|
||||||
|
content is hashed before writing: unchanged responses do not rewrite their
|
||||||
|
files. Their modification time is touched at most once per day (or half the
|
||||||
|
configured maximum age, when shorter) so recently validated snapshots remain
|
||||||
|
usable without writing on every poll. Changed files are replaced atomically,
|
||||||
|
and oldest cache entries are pruned at the configured bound. Read state and
|
||||||
|
recoverable reply/metadata drafts use versioned, atomic files beside the
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
Polling adapts to GitHub's reported rate-limit budget. It backs off as the
|
||||||
|
remaining budget gets low, honors server retry windows, and adds jitter to
|
||||||
|
avoid synchronized clients. Opening another PR or starting another refresh
|
||||||
|
cancels the superseded request.
|
||||||
|
|
||||||
|
GitHub's public APIs report whether a PR conflicts but do not expose its
|
||||||
|
conflicting file paths. For conflicting PRs only, `diple` performs a
|
||||||
|
read-only `git merge-tree` analysis in a temporary bare repository. It never
|
||||||
|
touches or inspects the current checkout, so Git, Jujutsu (`jj`), and directories
|
||||||
|
without a local repository behave identically. The analysis fetches the exact
|
||||||
|
remote base branch and pull-request head ref using the existing GitHub
|
||||||
|
credential. Results are memoized by the base and head commit, and failed scans
|
||||||
|
are retried after one minute.
|
||||||
|
|
||||||
Command-line flags override the configuration. `GH_REPO` overrides the
|
Command-line flags override the configuration. `GH_REPO` overrides the
|
||||||
configured repository when `--repo` is not provided. The corresponding flags
|
configured repository when `--repo` is not provided. The corresponding flags
|
||||||
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
|
||||||
`--thread-list-width`, `--path-scroll`, and `--path-scroll-interval`. Boolean
|
`--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
|
||||||
settings can be disabled explicitly, for example `--path-scroll=false`.
|
`--path-scroll`, and `--path-scroll-interval`, plus `--cache`,
|
||||||
|
`--cache-max-age`, `--cache-dir`, and `--editor-mode`.
|
||||||
|
Boolean settings can be disabled explicitly, for
|
||||||
|
example `--compact-reviews=false`.
|
||||||
|
|
||||||
## Keys
|
With the default `dashboard_mode = "hotkey"`, opening a PR goes directly to its
|
||||||
|
review threads and `d` opens the dashboard only when requested. Set
|
||||||
|
`dashboard_mode = "intermediate"` to follow picker → dashboard → review
|
||||||
|
threads instead.
|
||||||
|
|
||||||
|
Compact reviews aggregate submission counts by state and author. Reviews with
|
||||||
|
a written summary retain a compact one-line body, while timestamps and commit
|
||||||
|
SHAs are omitted. Set `compact_reviews = false` to restore the complete review
|
||||||
|
history and metadata.
|
||||||
|
|
||||||
|
## Default keys
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `h` / `l` | Focus the thread list / thread detail |
|
| `h` / `l` | Focus the thread list / thread detail |
|
||||||
| `j` / `k` | Move between threads or scroll the focused detail |
|
| `j` / `k` | Move between items or scroll the dashboard/focused detail |
|
||||||
| `?` | Show contextual keybinding help |
|
| `?` | Show contextual keybinding help |
|
||||||
| `/` | Fuzzy-search thread file paths |
|
| `H` | Open application health and diagnostics |
|
||||||
| `↑` / `↓` | Choose a fuzzy-search match |
|
| `d` | Open the current pull request dashboard |
|
||||||
|
| `e` | Edit the current PR title, target branch, and description from its dashboard |
|
||||||
|
| `a` | Enable or disable auto-merge from the dashboard |
|
||||||
|
| `M` | Merge now when GitHub reports that all represented requirements are satisfied |
|
||||||
|
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
|
||||||
|
| `F` | Clear active thread filters |
|
||||||
|
| `n` / `N` | Next / previous thread with a new update |
|
||||||
|
| `c` | Compose a reply to the selected thread |
|
||||||
|
| `R` | Resolve or unresolve the selected thread |
|
||||||
|
| `ctrl-p` / `ctrl-n` | Choose the previous / next fuzzy-search or branch-completion match |
|
||||||
| `g` / `G` | First / last item |
|
| `g` / `G` | First / last item |
|
||||||
| `enter` / `l` | Open a PR |
|
| `enter` / `l` | Open the selected PR dashboard or its review threads |
|
||||||
| `enter` | Toggle the selected review thread |
|
| `enter` | Toggle the selected review thread |
|
||||||
| `za` | Toggle the selected thread |
|
| `za` | Toggle the selected thread |
|
||||||
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
|
| `ctrl-d` / `ctrl-u` | Scroll thread detail or page through lists |
|
||||||
| `tab` | Hide or reveal the thread list |
|
| `tab` | Hide or reveal the thread list |
|
||||||
| `b` / `esc` | Return to the PR picker |
|
| `b` / `esc` | Return to the previous screen |
|
||||||
| `r` | Refresh now |
|
| `r` | Refresh now |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
|
|
||||||
|
The Health modal reports the interactive loop, configuration, GitHub API,
|
||||||
|
rate-limit budget and reset/retry time, disk cache, unread-state persistence,
|
||||||
|
draft recovery, core PR data, and secondary enrichment. Session warnings and
|
||||||
|
errors are retained there with their component and timestamp. Long diagnostics
|
||||||
|
wrap to the modal width. Refresh activity occupies a stable informational row
|
||||||
|
so polling does not reorder the report. Press `H` from the picker, dashboard,
|
||||||
|
or thread view; `b` or `esc` closes it without changing the underlying scroll
|
||||||
|
position.
|
||||||
|
|
||||||
|
The reply composer appears inline beneath the selected thread so its code and
|
||||||
|
comments remain visible while writing. It supports multiple lines: `enter`
|
||||||
|
inserts a newline, `ctrl-s` opens the rendered confirmation preview, and `esc`
|
||||||
|
cancels. Replies and resolution changes require an explicit `y` confirmation.
|
||||||
|
Write keys remain disabled for cached snapshots, during refreshes, and whenever
|
||||||
|
GitHub does not grant the corresponding capability.
|
||||||
|
|
||||||
|
Auto-merge and immediate merge actions are available from the dashboard and
|
||||||
|
always require confirmation. The selected method is the repository's first
|
||||||
|
available method in `squash`, `merge`, then `rebase` preference order. Both
|
||||||
|
mutations include the currently displayed head commit OID, so a force-push or
|
||||||
|
new commit prevents a stale merge. “Merge now” is gated for drafts, conflicts,
|
||||||
|
required reviews, required checks, unresolved required conversations, closed
|
||||||
|
PRs, and branches that require a merge queue; GitHub performs the final
|
||||||
|
permission and mergeability validation.
|
||||||
|
|
||||||
|
The dashboard editor works with raw Markdown so template checklists can be
|
||||||
|
updated directly. The active line is highlighted without inserting a
|
||||||
|
layout-changing block character. It opens with the description focused;
|
||||||
|
`tab` and `shift-tab` move between the description, title, and target branch.
|
||||||
|
When the target branch is focused, repository branches are recommended using
|
||||||
|
the typed text, likely branch names, the current/default branch, and each
|
||||||
|
branch's latest commit time. The list updates as you type. Use `ctrl-p` and
|
||||||
|
`ctrl-n` to select the previous or next result, then `tab` or `enter` to complete it;
|
||||||
|
pressing `tab` again moves to the description.
|
||||||
|
|
||||||
|
With the default `editing.mode = "vim"`, the description starts in Normal mode.
|
||||||
|
It supports `hjkl`, `0`, `^`, `$`, `gg`, `G`, `w`/`W`, `b`/`B`, `e`/`E`,
|
||||||
|
`f`/`F`/`t`/`T` with `;` and `,`, `i`/`a`/`I`/`A`, `o`/`O`, `s`, and
|
||||||
|
`x`/`X`. `s` removes the character under the cursor and enters Insert mode.
|
||||||
|
Soft-wrapped rows behave as visual editor lines for vertical and line-local
|
||||||
|
motions, but do not add newlines to the Markdown submitted to GitHub.
|
||||||
|
`ctrl-d` and `ctrl-u` move the cursor and viewport down or up by half a page,
|
||||||
|
including while extending a Visual selection.
|
||||||
|
`v` starts character-wise Visual mode and `V` starts visual-line selection;
|
||||||
|
`d` or `x` deletes the selection, `y` copies it to the system clipboard, and
|
||||||
|
`p` pastes from the system clipboard. Normal mode uses a block cursor, while
|
||||||
|
Insert mode uses the terminal's hardware bar cursor at the boundary between
|
||||||
|
characters without hiding or shifting either character.
|
||||||
|
The description retains its raw Markdown while headings, emphasis, inline
|
||||||
|
code, links, quote markers, and HTML comments receive syntax highlighting.
|
||||||
|
Highlighting consists only of zero-width terminal styling and cannot alter
|
||||||
|
wrapping, selection, clipboard contents, cursor offsets, or submitted text.
|
||||||
|
`esc` returns from Insert to Normal mode; a second `esc` cancels the editor.
|
||||||
|
Set `editing.mode = "standard"` for direct insertion with arrow,
|
||||||
|
`home`, and `end` navigation. Title and target branch remain standard inputs
|
||||||
|
in either mode. `ctrl-s` opens an explicit confirmation. If the title,
|
||||||
|
description, or target branch changes remotely while the editor is open,
|
||||||
|
submission is blocked rather than overwriting the newer metadata.
|
||||||
|
|
||||||
## Current scope
|
## Current scope
|
||||||
|
|
||||||
The application is intentionally read-only. GitHub's GraphQL API currently
|
The application can reply to review threads, resolve or unresolve them, update
|
||||||
limits this client to the first 100 review threads and first 100 comments per
|
the PR title, target branch, and description, enable or disable auto-merge, and
|
||||||
thread; the UI warns when the thread list is truncated. GitHub features which
|
merge an eligible PR immediately. Comment reactions remain read-only. Other
|
||||||
|
write operations remain disabled. The dashboard shows the capability gate,
|
||||||
|
including why each action is unavailable. Read state
|
||||||
|
persists beside the configuration, and recent PR data is cached for offline
|
||||||
|
fallback. Check contexts and annotations are paginated. GitHub features which
|
||||||
depend on server-side context, such as unfurling issue references or displaying
|
depend on server-side context, such as unfurling issue references or displaying
|
||||||
uploaded images, are represented textually in the terminal.
|
uploaded images, are represented textually in the terminal. See
|
||||||
|
[`TODO.md`](TODO.md) for remaining read-only work and write-support preparation.
|
||||||
|
|||||||
126
TODO.md
Normal file
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"`
|
Display DisplayConfig `toml:"display"`
|
||||||
Paths PathConfig `toml:"paths"`
|
Paths PathConfig `toml:"paths"`
|
||||||
Threads ThreadConfig `toml:"threads"`
|
Threads ThreadConfig `toml:"threads"`
|
||||||
|
Cache CacheConfig `toml:"cache"`
|
||||||
|
Editing EditingConfig `toml:"editing"`
|
||||||
|
KeyBindings KeyBindings `toml:"keybindings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayConfig struct {
|
type DisplayConfig struct {
|
||||||
FoldResolved bool `toml:"fold_resolved"`
|
FoldResolved bool `toml:"fold_resolved"`
|
||||||
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
ThreadListWidthPercent int `toml:"thread_list_width_percent"`
|
||||||
|
DashboardMode string `toml:"dashboard_mode"`
|
||||||
|
CompactReviews bool `toml:"compact_reviews"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PathConfig struct {
|
type PathConfig struct {
|
||||||
@@ -51,6 +56,17 @@ type ThreadConfig struct {
|
|||||||
WithinStatus string `toml:"within_status"`
|
WithinStatus string `toml:"within_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CacheConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MaxAge configDuration `toml:"max_age"`
|
||||||
|
Directory string `toml:"directory"`
|
||||||
|
MaxEntries int `toml:"max_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditingConfig struct {
|
||||||
|
Mode string `toml:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Theme: "dark",
|
Theme: "dark",
|
||||||
@@ -60,6 +76,8 @@ func defaultConfig() Config {
|
|||||||
Display: DisplayConfig{
|
Display: DisplayConfig{
|
||||||
FoldResolved: true,
|
FoldResolved: true,
|
||||||
ThreadListWidthPercent: 33,
|
ThreadListWidthPercent: 33,
|
||||||
|
DashboardMode: "hotkey",
|
||||||
|
CompactReviews: true,
|
||||||
},
|
},
|
||||||
Paths: PathConfig{
|
Paths: PathConfig{
|
||||||
Scroll: false,
|
Scroll: false,
|
||||||
@@ -69,35 +87,55 @@ func defaultConfig() Config {
|
|||||||
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
StatusOrder: []string{"unresolved", "outdated", "resolved"},
|
||||||
WithinStatus: "file",
|
WithinStatus: "file",
|
||||||
},
|
},
|
||||||
|
Cache: CacheConfig{
|
||||||
|
Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}, MaxEntries: 200,
|
||||||
|
},
|
||||||
|
Editing: EditingConfig{Mode: "vim"},
|
||||||
|
KeyBindings: defaultKeyBindings(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configPath() (string, error) {
|
func configPath() (string, error) {
|
||||||
|
if path := os.Getenv("DIPLE_CONFIG"); path != "" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
// Preserve the old override during the rename so existing scripts do not
|
||||||
|
// silently start with a fresh configuration.
|
||||||
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
|
if path := os.Getenv("GH_THREADS_CONFIG"); path != "" {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
|
if base := os.Getenv("XDG_CONFIG_HOME"); base != "" {
|
||||||
return filepath.Join(base, "gh-threads", "config.toml"), nil
|
return firstExistingOrDefault(
|
||||||
|
filepath.Join(base, "diple", "config.toml"),
|
||||||
|
filepath.Join(base, "gh-threads", "config.toml"),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
base, err := os.UserConfigDir()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("find user config directory: %w", err)
|
return "", fmt.Errorf("find user config directory: %w", err)
|
||||||
}
|
}
|
||||||
preferred := filepath.Join(base, "gh-threads", "config.toml")
|
preferred := filepath.Join(base, "diple", "config.toml")
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("find home directory: %w", err)
|
return "", fmt.Errorf("find home directory: %w", err)
|
||||||
}
|
}
|
||||||
fallback := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
dotConfig := filepath.Join(home, ".config", "diple", "config.toml")
|
||||||
return existingConfigPath(preferred, fallback), nil
|
legacyPreferred := filepath.Join(base, "gh-threads", "config.toml")
|
||||||
|
legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml")
|
||||||
|
return firstExistingOrDefault(
|
||||||
|
preferred, dotConfig, legacyPreferred, legacyDotConfig,
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func existingConfigPath(preferred, fallback string) string {
|
func existingConfigPath(preferred, fallback string) string {
|
||||||
if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) {
|
return firstExistingOrDefault(preferred, fallback)
|
||||||
return preferred
|
}
|
||||||
}
|
|
||||||
if _, err := os.Stat(fallback); err == nil {
|
func firstExistingOrDefault(preferred string, alternatives ...string) string {
|
||||||
return fallback
|
for _, candidate := range append([]string{preferred}, alternatives...) {
|
||||||
|
if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return preferred
|
return preferred
|
||||||
}
|
}
|
||||||
@@ -123,15 +161,15 @@ func loadConfig(path string, required bool) (Config, error) {
|
|||||||
|
|
||||||
func validateConfig(config Config) error {
|
func validateConfig(config Config) error {
|
||||||
switch config.Theme {
|
switch config.Theme {
|
||||||
case "dark", "light":
|
case "dark", "light", "no-color", "high-contrast":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("theme must be dark or light")
|
return fmt.Errorf("theme must be dark or light")
|
||||||
}
|
}
|
||||||
if config.RefreshInterval.Duration < 2*time.Second {
|
if config.RefreshInterval.Duration < 2*time.Second {
|
||||||
return fmt.Errorf("refresh_interval must be at least 2s")
|
return fmt.Errorf("refresh_interval must be at least 2s")
|
||||||
}
|
}
|
||||||
if config.Limit < 1 || config.Limit > 100 {
|
if config.Limit < 1 || config.Limit > 1000 {
|
||||||
return fmt.Errorf("limit must be between 1 and 100")
|
return fmt.Errorf("limit must be between 1 and 1000")
|
||||||
}
|
}
|
||||||
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
|
if config.Paths.ScrollInterval.Duration < 50*time.Millisecond {
|
||||||
return fmt.Errorf("paths.scroll_interval must be at least 50ms")
|
return fmt.Errorf("paths.scroll_interval must be at least 50ms")
|
||||||
@@ -139,6 +177,11 @@ func validateConfig(config Config) error {
|
|||||||
if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 {
|
if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 {
|
||||||
return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60")
|
return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60")
|
||||||
}
|
}
|
||||||
|
switch config.Display.DashboardMode {
|
||||||
|
case "intermediate", "hotkey":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey")
|
||||||
|
}
|
||||||
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
|
if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -150,9 +193,34 @@ func validateConfig(config Config) error {
|
|||||||
if config.ShowAll && config.Repository == "" {
|
if config.ShowAll && config.Repository == "" {
|
||||||
return fmt.Errorf("show_all requires repository")
|
return fmt.Errorf("show_all requires repository")
|
||||||
}
|
}
|
||||||
|
if config.Cache.MaxAge.Duration < 0 {
|
||||||
|
return fmt.Errorf("cache.max_age must not be negative")
|
||||||
|
}
|
||||||
|
if config.Cache.MaxEntries < 10 || config.Cache.MaxEntries > 10000 {
|
||||||
|
return fmt.Errorf("cache.max_entries must be between 10 and 10000")
|
||||||
|
}
|
||||||
|
switch config.Editing.Mode {
|
||||||
|
case "standard", "vim":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("editing.mode must be standard or vim")
|
||||||
|
}
|
||||||
|
if err := validateKeyBindings(config.KeyBindings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultCacheDir() (string, error) {
|
||||||
|
base, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("find user cache directory: %w", err)
|
||||||
|
}
|
||||||
|
return firstExistingOrDefault(
|
||||||
|
filepath.Join(base, "diple"),
|
||||||
|
filepath.Join(base, "gh-threads"),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateThreadStatusOrder(order []string) error {
|
func validateThreadStatusOrder(order []string) error {
|
||||||
if len(order) != 3 {
|
if len(order) != 3 {
|
||||||
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")
|
return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once")
|
||||||
|
|||||||
131
config_test.go
131
config_test.go
@@ -18,7 +18,9 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
|
|||||||
if got.Theme != want.Theme ||
|
if got.Theme != want.Theme ||
|
||||||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
||||||
got.Paths.Scroll != want.Paths.Scroll ||
|
got.Paths.Scroll != want.Paths.Scroll ||
|
||||||
got.Display.FoldResolved != want.Display.FoldResolved {
|
got.Display.FoldResolved != want.Display.FoldResolved ||
|
||||||
|
got.Display.CompactReviews != want.Display.CompactReviews ||
|
||||||
|
got.Editing.Mode != "vim" {
|
||||||
t.Fatalf("defaults = %#v, want %#v", got, want)
|
t.Fatalf("defaults = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,8 @@ endpoint = "https://github.example.com/api/graphql"
|
|||||||
[display]
|
[display]
|
||||||
fold_resolved = false
|
fold_resolved = false
|
||||||
thread_list_width_percent = 45
|
thread_list_width_percent = 45
|
||||||
|
dashboard_mode = "hotkey"
|
||||||
|
compact_reviews = false
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
scroll = true
|
scroll = true
|
||||||
@@ -44,6 +48,18 @@ scroll_interval = "125ms"
|
|||||||
[threads]
|
[threads]
|
||||||
status_order = ["resolved", "unresolved", "outdated"]
|
status_order = ["resolved", "unresolved", "outdated"]
|
||||||
within_status = "timestamp"
|
within_status = "timestamp"
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
enabled = false
|
||||||
|
max_age = "48h"
|
||||||
|
directory = "/tmp/diple-cache"
|
||||||
|
|
||||||
|
[editing]
|
||||||
|
mode = "standard"
|
||||||
|
|
||||||
|
[keybindings.navigation]
|
||||||
|
down = ["ctrl+j"]
|
||||||
|
up = ["ctrl+k"]
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -58,13 +74,69 @@ within_status = "timestamp"
|
|||||||
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
|
if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second ||
|
||||||
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 ||
|
||||||
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 ||
|
||||||
|
got.Display.DashboardMode != "hotkey" ||
|
||||||
|
got.Display.CompactReviews ||
|
||||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
||||||
got.Threads.WithinStatus != "timestamp" {
|
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
||||||
|
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/diple-cache" ||
|
||||||
|
got.Editing.Mode != "standard" ||
|
||||||
|
strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" ||
|
||||||
|
strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" {
|
||||||
t.Fatalf("config = %#v", got)
|
t.Fatalf("config = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
config.KeyBindings.Navigation.Down = nil
|
||||||
|
err := validateConfig(config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "keybindings.navigation.down") {
|
||||||
|
t.Fatalf("empty binding error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsKeyConflictsInTheSameContext(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
change func(*Config)
|
||||||
|
context string
|
||||||
|
actions []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "thread navigation and reply",
|
||||||
|
change: func(config *Config) {
|
||||||
|
config.KeyBindings.Threads.Reply = []string{"j"}
|
||||||
|
},
|
||||||
|
context: "review threads",
|
||||||
|
actions: []string{"down", "reply"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vim motion and cancel",
|
||||||
|
change: func(config *Config) {
|
||||||
|
config.KeyBindings.Input.Cancel = []string{"b"}
|
||||||
|
},
|
||||||
|
context: "Vim Normal mode",
|
||||||
|
actions: []string{"cancel", "word_backward"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
test.change(&config)
|
||||||
|
err := validateConfig(config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.context) {
|
||||||
|
t.Fatalf("conflict error = %v", err)
|
||||||
|
}
|
||||||
|
for _, action := range test.actions {
|
||||||
|
if !strings.Contains(err.Error(), action) {
|
||||||
|
t.Fatalf("conflict error does not name %q: %v", action, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
|
func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "config.toml")
|
path := filepath.Join(t.TempDir(), "config.toml")
|
||||||
if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil {
|
||||||
@@ -77,20 +149,33 @@ func TestLoadConfigRejectsUnknownSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
|
func TestConfigPathHonorsEnvironmentOverride(t *testing.T) {
|
||||||
t.Setenv("GH_THREADS_CONFIG", "/tmp/custom-gh-threads.toml")
|
t.Setenv("DIPLE_CONFIG", "/tmp/custom-diple.toml")
|
||||||
|
t.Setenv("GH_THREADS_CONFIG", "")
|
||||||
got, err := configPath()
|
got, err := configPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != "/tmp/custom-gh-threads.toml" {
|
if got != "/tmp/custom-diple.toml" {
|
||||||
t.Fatalf("config path = %q", got)
|
t.Fatalf("config path = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigPathHonorsLegacyEnvironmentOverride(t *testing.T) {
|
||||||
|
t.Setenv("DIPLE_CONFIG", "")
|
||||||
|
t.Setenv("GH_THREADS_CONFIG", "/tmp/legacy-gh-threads.toml")
|
||||||
|
got, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "/tmp/legacy-gh-threads.toml" {
|
||||||
|
t.Fatalf("legacy config path = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
preferred := filepath.Join(root, "Library", "Application Support", "gh-threads", "config.toml")
|
preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml")
|
||||||
fallback := filepath.Join(root, ".config", "gh-threads", "config.toml")
|
fallback := filepath.Join(root, ".config", "diple", "config.toml")
|
||||||
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -112,14 +197,30 @@ func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstExistingConfigPathFallsBackToLegacyName(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
current := filepath.Join(root, "diple", "config.toml")
|
||||||
|
legacy := filepath.Join(root, "gh-threads", "config.toml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(legacy, []byte("theme = \"dark\"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := firstExistingOrDefault(current, legacy); got != legacy {
|
||||||
|
t.Fatalf("migration config path = %q, want %q", got, legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
|
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
|
||||||
t.Setenv("GH_THREADS_CONFIG", "")
|
t.Setenv("GH_THREADS_CONFIG", "")
|
||||||
|
t.Setenv("DIPLE_CONFIG", "")
|
||||||
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
||||||
got, err := configPath()
|
got, err := configPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := "/tmp/xdg-config/gh-threads/config.toml"
|
want := "/tmp/xdg-config/diple/config.toml"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("config path = %q, want %q", got, want)
|
t.Fatalf("config path = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
@@ -145,3 +246,19 @@ func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) {
|
|||||||
t.Fatal("unknown within-status ordering was accepted")
|
t.Fatal("unknown within-status ordering was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
config.Display.DashboardMode = "sometimes"
|
||||||
|
if err := validateConfig(config); err == nil {
|
||||||
|
t.Fatal("unknown dashboard mode was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
config.Editing.Mode = "emacs"
|
||||||
|
if err := validateConfig(config); err == nil {
|
||||||
|
t.Fatal("unknown editor mode was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
190
conflicts.go
Normal file
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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestGraphQLRequestRecordsRateLimitHeaders(t *testing.T) {
|
||||||
|
reset := time.Now().Add(time.Hour).Unix()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("X-RateLimit-Limit", "5000")
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", "321")
|
||||||
|
w.Header().Set("X-RateLimit-Used", "4679")
|
||||||
|
w.Header().Set("X-RateLimit-Reset", fmtInt64(reset))
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"octocat"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
var target struct {
|
||||||
|
Viewer struct{ Login string }
|
||||||
|
}
|
||||||
|
if err := client.query(context.Background(), "query { viewer { login } }", nil, &target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rate := client.RateLimit()
|
||||||
|
if rate.Limit != 5000 || rate.Remaining != 321 || rate.Used != 4679 ||
|
||||||
|
rate.ResetAt.Unix() != reset || rate.UpdatedAt.IsZero() {
|
||||||
|
t.Fatalf("rate limit = %#v", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtInt64(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBranchesPaginatesAndMarksTheDefaultBranch(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(request.Query, "query RepositoryBranches") {
|
||||||
|
t.Fatalf("unexpected query: %s", request.Query)
|
||||||
|
}
|
||||||
|
if requests == 1 {
|
||||||
|
if request.Variables["after"] != nil {
|
||||||
|
t.Fatalf("first cursor = %#v", request.Variables["after"])
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||||
|
"defaultBranchRef":{"name":"main"},
|
||||||
|
"refs":{"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[
|
||||||
|
{"name":"feature/old","target":{"committedDate":"2024-01-01T00:00:00Z"}},
|
||||||
|
{"name":"main","target":{"committedDate":"2025-01-01T00:00:00Z"}}
|
||||||
|
]}
|
||||||
|
}}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Variables["after"] != "next" {
|
||||||
|
t.Fatalf("second cursor = %#v", request.Variables["after"])
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||||
|
"defaultBranchRef":{"name":"main"},
|
||||||
|
"refs":{"pageInfo":{"hasNextPage":false},"nodes":[
|
||||||
|
{"name":"release/2.0","target":{"committedDate":"2026-07-28T00:00:00Z"}}
|
||||||
|
]}
|
||||||
|
}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
branches, err := client.ListBranches(context.Background(), "o", "r")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if requests != 2 || len(branches) != 3 {
|
||||||
|
t.Fatalf("requests=%d branches=%#v", requests, branches)
|
||||||
|
}
|
||||||
|
if branches[0].Name != "main" || !branches[0].IsDefault {
|
||||||
|
t.Fatalf("default branch was not first and marked: %#v", branches)
|
||||||
|
}
|
||||||
|
wantUpdated := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC)
|
||||||
|
if branches[1].Name != "release/2.0" || !branches[1].UpdatedAt.Equal(wantUpdated) {
|
||||||
|
t.Fatalf("fresh branch was not decoded and sorted: %#v", branches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
|
func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||||
@@ -73,6 +158,40 @@ func TestListPullRequestsRejectsGlobalShowAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListPullRequestsPaginatesToConfiguredLimit(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cursor := request.Variables["after"]
|
||||||
|
if requests == 1 {
|
||||||
|
if cursor != nil || request.Variables["first"] != float64(100) {
|
||||||
|
t.Fatalf("first page variables = %#v", request.Variables)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
|
||||||
|
"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[]}}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cursor != "next" || request.Variables["first"] != float64(50) {
|
||||||
|
t.Fatalf("second page variables = %#v", request.Variables)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"viewer":{"login":"me"},"search":{
|
||||||
|
"pageInfo":{"hasNextPage":false},"nodes":[]}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
if _, err := client.ListPullRequests(context.Background(), "", "", 150, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if requests != 2 {
|
||||||
|
t.Fatalf("requests = %d, want 2", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGraphQLErrorsAreReturned(t *testing.T) {
|
func TestGraphQLErrorsAreReturned(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))
|
_, _ = w.Write([]byte(`{"errors":[{"message":"no access"}]}`))
|
||||||
@@ -85,25 +204,333 @@ func TestGraphQLErrorsAreReturned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestThreadWriteMutationsUseThreadIDs(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input, _ := request.Variables["input"].(map[string]any)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "unresolveReviewThread"):
|
||||||
|
if input["threadId"] != "thread" {
|
||||||
|
t.Fatalf("unresolve input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"unresolveReviewThread":{"thread":{
|
||||||
|
"id":"thread","path":"a.go","isResolved":false,"viewerCanResolve":true
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "resolveReviewThread"):
|
||||||
|
if input["threadId"] != "thread" {
|
||||||
|
t.Fatalf("resolve input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{
|
||||||
|
"id":"thread","path":"a.go","isResolved":true,"viewerCanUnresolve":true
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "addPullRequestReviewThreadReply"):
|
||||||
|
if input["pullRequestReviewThreadId"] != "thread" || input["body"] != "reply body" {
|
||||||
|
t.Fatalf("reply input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"addPullRequestReviewThreadReply":{"comment":{
|
||||||
|
"id":"comment","body":"reply body","createdAt":"2026-01-01T00:00:00Z",
|
||||||
|
"url":"https://example/comment","author":{"login":"me"}
|
||||||
|
}}}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected mutation: %s", request.Query)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
resolved, err := client.SetThreadResolved(context.Background(), "thread", true)
|
||||||
|
if err != nil || !resolved.IsResolved || !resolved.ViewerCanUnresolve {
|
||||||
|
t.Fatalf("resolve result = %#v, error = %v", resolved, err)
|
||||||
|
}
|
||||||
|
unresolved, err := client.SetThreadResolved(context.Background(), "thread", false)
|
||||||
|
if err != nil || unresolved.IsResolved || !unresolved.ViewerCanResolve {
|
||||||
|
t.Fatalf("unresolve result = %#v, error = %v", unresolved, err)
|
||||||
|
}
|
||||||
|
comment, err := client.ReplyToThread(context.Background(), "thread", "reply body")
|
||||||
|
if err != nil || comment.ID != "comment" || comment.Author != "me" || comment.Body != "reply body" {
|
||||||
|
t.Fatalf("reply result = %#v, error = %v", comment, err)
|
||||||
|
}
|
||||||
|
if requests != 3 {
|
||||||
|
t.Fatalf("mutation requests = %d, want 3", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(request.Query, "mutation UpdatePullRequest") {
|
||||||
|
t.Fatalf("unexpected mutation:\n%s", request.Query)
|
||||||
|
}
|
||||||
|
input := request.Variables["input"].(map[string]any)
|
||||||
|
if input["pullRequestId"] != "pr-id" || input["title"] != "New title" ||
|
||||||
|
input["body"] != "- [x] done" || input["baseRefName"] != "release" {
|
||||||
|
t.Fatalf("update input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"updatePullRequest":{"pullRequest":{
|
||||||
|
"id":"pr-id","title":"New title","body":"- [x] done","baseRefName":"release",
|
||||||
|
"updatedAt":"2026-07-28T12:00:00Z","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN"
|
||||||
|
}}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
got, err := client.UpdatePullRequest(context.Background(), "pr-id", PullRequestMetadata{
|
||||||
|
Title: "New title", Body: "- [x] done", BaseRef: "release",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Title != "New title" || got.Body != "- [x] done" || got.BaseRef != "release" ||
|
||||||
|
got.Mergeable != "UNKNOWN" || got.UpdatedAt.IsZero() {
|
||||||
|
t.Fatalf("updated pull request = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeAndAutoMergeMutationsUseExpectedHeadOID(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := request.Variables["input"].(map[string]any)
|
||||||
|
if input["pullRequestId"] != "pr-id" {
|
||||||
|
t.Fatalf("mutation input = %#v", input)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "EnablePullRequestAutoMerge"):
|
||||||
|
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
|
||||||
|
t.Fatalf("enable input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"enablePullRequestAutoMerge":{"pullRequest":{
|
||||||
|
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-07-28T12:00:00Z",
|
||||||
|
"enabledBy":{"login":"me"}}
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "DisablePullRequestAutoMerge"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"disablePullRequestAutoMerge":{
|
||||||
|
"pullRequest":{"id":"pr-id","autoMergeRequest":null}
|
||||||
|
}}}`))
|
||||||
|
case strings.Contains(request.Query, "MergePullRequest"):
|
||||||
|
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
|
||||||
|
t.Fatalf("merge input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"mergePullRequest":{"pullRequest":{
|
||||||
|
"merged":true,"mergedAt":"2026-07-28T12:01:00Z"
|
||||||
|
}}}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected mutation:\n%s", request.Query)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
auto, err := client.SetPullRequestAutoMerge(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH", true,
|
||||||
|
)
|
||||||
|
if err != nil || auto == nil || auto.MergeMethod != "SQUASH" || auto.EnabledBy != "me" {
|
||||||
|
t.Fatalf("enable result = %#v, error = %v", auto, err)
|
||||||
|
}
|
||||||
|
if _, err := client.SetPullRequestAutoMerge(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH", false,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
merged, err := client.MergePullRequest(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH",
|
||||||
|
)
|
||||||
|
if err != nil || !merged.Merged || merged.MergedAt.IsZero() {
|
||||||
|
t.Fatalf("merge result = %#v, error = %v", merged, err)
|
||||||
|
}
|
||||||
|
if requests != 3 {
|
||||||
|
t.Fatalf("mutation requests = %d, want 3", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "query CheckContextsPage"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"node":{"contexts":{
|
||||||
|
"pageInfo":{"hasNextPage":false},"nodes":[{
|
||||||
|
"id":"check-2","name":"lint","conclusion":"FAILURE"
|
||||||
|
}]}}}}`))
|
||||||
|
case strings.Contains(request.Query, "query CheckAnnotationsPage"):
|
||||||
|
if request.Variables["after"] == nil {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
|
||||||
|
"pageInfo":{"hasNextPage":true,"endCursor":"annotation-next"},
|
||||||
|
"nodes":[{"path":"a.go","location":{"start":{"line":4},"end":{"line":4}},
|
||||||
|
"annotationLevel":"FAILURE","message":"first"}]
|
||||||
|
}}}}`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"node":{"annotations":{
|
||||||
|
"pageInfo":{"hasNextPage":false},
|
||||||
|
"nodes":[{"path":"b.go","location":{"start":{"line":8},"end":{"line":8}},
|
||||||
|
"annotationLevel":"WARNING","message":"second"}]
|
||||||
|
}}}}`))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected query: %s", request.Query)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
nodes, err := client.allCheckContexts(context.Background(), githubCheckContextConnection{
|
||||||
|
PageInfo: githubPageInfo{HasNextPage: true, EndCursor: "context-next"},
|
||||||
|
Nodes: []githubCheckContext{{ID: "check-1", Name: "tests", Conclusion: "SUCCESS"}},
|
||||||
|
}, "rollup")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
annotations, err := client.checkAnnotations(context.Background(), nodes[1].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(nodes) != 2 || len(annotations) != 2 ||
|
||||||
|
annotations[0].Location.Start.Line != 4 ||
|
||||||
|
annotations[1].Location.End.Line != 8 {
|
||||||
|
t.Fatalf("paginated checks = %#v", nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckQueriesUseCurrentGitHubSchemaShape(t *testing.T) {
|
||||||
|
for name, query := range map[string]string{"annotations": checkAnnotationsPageQuery} {
|
||||||
|
if strings.Contains(query, "output {") ||
|
||||||
|
strings.Contains(query, "nodes { path startLine endLine annotationLevel") ||
|
||||||
|
!strings.Contains(query, "location { start { line column } end { line column } }") {
|
||||||
|
t.Fatalf("%s query uses obsolete CheckRun/CheckAnnotation fields:\n%s", name, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, query := range map[string]string{
|
||||||
|
"details": detailsQuery, "context page": checkContextsPageQuery,
|
||||||
|
} {
|
||||||
|
if strings.Contains(query, "annotations(first:") {
|
||||||
|
t.Fatalf("%s query eagerly loads annotations:\n%s", name, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "query ReviewThreadsPage"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviewThreads":{
|
||||||
|
"pageInfo":{"hasNextPage":false,"endCursor":null},
|
||||||
|
"nodes":[{"id":"thread-2","path":"b.go","line":20,"diffSide":"RIGHT",
|
||||||
|
"isResolved":false,"isOutdated":false,"viewerCanResolve":true,
|
||||||
|
"comments":{"pageInfo":{"hasNextPage":true,"endCursor":"comment-cursor"},
|
||||||
|
"nodes":[{"id":"review-comment-2a","body":"first","createdAt":"2026-01-02T00:00:00Z"}]}}]
|
||||||
|
}}}}}`))
|
||||||
|
case strings.Contains(request.Query, "query ReviewCommentsPage"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"node":{"comments":{
|
||||||
|
"pageInfo":{"hasNextPage":false,"endCursor":null},
|
||||||
|
"nodes":[{"id":"review-comment-2b","body":"second","createdAt":"2026-01-03T00:00:00Z"}]
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "query ConversationPage"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"comments":{
|
||||||
|
"totalCount":2,"pageInfo":{"hasNextPage":false,"endCursor":null},
|
||||||
|
"nodes":[{"id":"conversation-2","body":"reply","createdAt":"2026-01-03T00:00:00Z"}]
|
||||||
|
}}}}}`))
|
||||||
|
case strings.Contains(request.Query, "query ReviewsPage"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{"reviews":{
|
||||||
|
"pageInfo":{"hasNextPage":false,"endCursor":null},
|
||||||
|
"nodes":[{"id":"review-2","body":"approved","state":"APPROVED","submittedAt":"2026-01-03T00:00:00Z"}]
|
||||||
|
}}}}}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
|
||||||
|
"id":"pr","number":1,"title":"PR","url":"u","createdAt":"2026-01-01T00:00:00Z",
|
||||||
|
"updatedAt":"2026-01-01T00:00:00Z","author":{"login":"alice"},
|
||||||
|
"assignees":{"nodes":[]},"labels":{"nodes":[]},"reviewRequests":{"nodes":[]},
|
||||||
|
"latestReviews":{"nodes":[]},"commits":{"totalCount":1,"nodes":[]},
|
||||||
|
"comments":{"totalCount":2,"pageInfo":{"hasNextPage":true,"endCursor":"conversation-cursor"},
|
||||||
|
"nodes":[{"id":"conversation-1","body":"start","createdAt":"2026-01-01T00:00:00Z"}]},
|
||||||
|
"reviews":{"pageInfo":{"hasNextPage":true,"endCursor":"review-cursor"},
|
||||||
|
"nodes":[{"id":"review-1","body":"changes","state":"CHANGES_REQUESTED","submittedAt":"2026-01-02T00:00:00Z"}]},
|
||||||
|
"reviewThreads":{"pageInfo":{"hasNextPage":true,"endCursor":"thread-cursor"},
|
||||||
|
"nodes":[{"id":"thread-1","path":"a.go","line":10,"diffSide":"RIGHT",
|
||||||
|
"isResolved":false,"isOutdated":false,
|
||||||
|
"comments":{"pageInfo":{"hasNextPage":false},
|
||||||
|
"nodes":[{"id":"review-comment-1","body":"fix","createdAt":"2026-01-01T00:00:00Z"}]}}]}
|
||||||
|
}}}}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
got, err := client.GetPullRequest(context.Background(), "o", "r", 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Threads) != 2 || len(got.Threads[1].Comments) != 2 ||
|
||||||
|
len(got.Conversation) != 2 || len(got.Reviews) != 2 {
|
||||||
|
t.Fatalf("paginated details were incomplete: %#v", got)
|
||||||
|
}
|
||||||
|
if !got.Permissions.CanResolveAny || got.Permissions.Repository != "WRITE" {
|
||||||
|
t.Fatalf("permissions = %#v", got.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"data":{"repository":{"pullRequest":{
|
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE",
|
||||||
|
"squashMergeAllowed":true,"mergeCommitAllowed":false,"rebaseMergeAllowed":true,
|
||||||
|
"pullRequest":{
|
||||||
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
|
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
|
||||||
"updatedAt":"2026-01-01T00:00:00Z","mergeable":"MERGEABLE","reviewDecision":"APPROVED",
|
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
||||||
"baseRefName":"main","headRefName":"fix","author":{"login":"zam"},
|
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
||||||
|
"additions":12,"deletions":4,"changedFiles":3,
|
||||||
|
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
|
||||||
|
"viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true,
|
||||||
|
"viewerCanEnableAutoMerge":false,"viewerCanDisableAutoMerge":true,
|
||||||
|
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-01-01T01:00:00Z",
|
||||||
|
"enabledBy":{"login":"zam"}},
|
||||||
|
"baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true,
|
||||||
|
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
|
||||||
|
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
|
||||||
|
"author":{"login":"zam"},
|
||||||
"assignees":{"nodes":[{"login":"sam"}]},
|
"assignees":{"nodes":[{"login":"sam"}]},
|
||||||
|
"labels":{"nodes":[{"name":"bug"},{"name":"backend"}]},
|
||||||
|
"milestone":{"title":"v2"},
|
||||||
|
"comments":{"totalCount":5},
|
||||||
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
|
"reviewRequests":{"nodes":[{"requestedReviewer":{"login":"lee"}}]},
|
||||||
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
|
"latestReviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"pat"}}]},
|
||||||
"commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]},
|
"commits":{"totalCount":7,"nodes":[{"commit":{"oid":"abcdef0123456789",
|
||||||
|
"statusCheckRollup":{"state":"FAILURE","contexts":{"nodes":[
|
||||||
|
{"name":"tests","status":"COMPLETED","conclusion":"FAILURE","detailsUrl":"https://checks/tests"},
|
||||||
|
{"context":"legacy","state":"SUCCESS","targetUrl":"https://checks/legacy"}
|
||||||
|
]}}}}]},
|
||||||
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
|
"reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{
|
||||||
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
|
"id":"t","isResolved":false,"isOutdated":true,"path":"main.go",
|
||||||
|
"viewerCanResolve":true,
|
||||||
"line":null,"originalLine":42,"diffSide":"RIGHT",
|
"line":null,"originalLine":42,"diffSide":"RIGHT",
|
||||||
"startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT",
|
"startLine":null,"originalStartLine":40,"startDiffSide":"RIGHT",
|
||||||
"comments":{"pageInfo":{"hasNextPage":true},"nodes":[{
|
"comments":{"pageInfo":{"hasNextPage":false},"nodes":[{
|
||||||
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
|
"id":"c","body":"change this","diffHunk":"@@ -1 +1 @@","createdAt":"2026-01-01T00:00:00Z",
|
||||||
"url":"cu","author":{"login":"reviewer"},"outdated":true,
|
"url":"cu","author":{"login":"reviewer"},"outdated":true,
|
||||||
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
|
"line":100,"startLine":99,"originalLine":42,"originalStartLine":40,
|
||||||
"originalCommit":{"oid":"0123456789abcdef"}
|
"originalCommit":{"oid":"0123456789abcdef"},
|
||||||
|
"reactionGroups":[
|
||||||
|
{"content":"THUMBS_UP","viewerHasReacted":true,"reactors":{"totalCount":3}},
|
||||||
|
{"content":"EYES","viewerHasReacted":false,"reactors":{"totalCount":1}},
|
||||||
|
{"content":"HEART","viewerHasReacted":false,"reactors":{"totalCount":0}}
|
||||||
|
]
|
||||||
}]}
|
}]}
|
||||||
}]}
|
}]}
|
||||||
}}}}`))
|
}}}}`))
|
||||||
@@ -118,8 +545,26 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
|||||||
if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" {
|
if got.CheckState != "FAILURE" || got.BaseRef != "main" || got.HeadRef != "fix" || got.ReviewDecision != "APPROVED" {
|
||||||
t.Fatalf("unexpected metadata: %#v", got)
|
t.Fatalf("unexpected metadata: %#v", got)
|
||||||
}
|
}
|
||||||
|
if got.MergeState != "CLEAN" || got.Additions != 12 || got.Deletions != 4 ||
|
||||||
|
got.ChangedFiles != 3 || got.CommitCount != 7 || got.CommentCount != 5 ||
|
||||||
|
got.Milestone != "v2" || strings.Join(got.Labels, ",") != "bug,backend" ||
|
||||||
|
got.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("unexpected dashboard metadata: %#v", got)
|
||||||
|
}
|
||||||
|
if got.HeadOID != "abcdef0123456789" || len(got.Checks) != 2 ||
|
||||||
|
got.Checks[0].Name != "tests" || got.Checks[1].Name != "legacy" ||
|
||||||
|
got.Permissions.Repository != "WRITE" || !got.Permissions.CanUpdatePR ||
|
||||||
|
!got.Permissions.CanResolveAny || got.Requirements.ApprovalsRequired != 2 ||
|
||||||
|
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
|
||||||
|
t.Fatalf("unexpected read capabilities: %#v", got)
|
||||||
|
}
|
||||||
|
if got.AutoMerge == nil || got.AutoMerge.MergeMethod != "SQUASH" ||
|
||||||
|
got.AutoMerge.EnabledBy != "zam" || !got.Permissions.CanDisableMerge ||
|
||||||
|
strings.Join(got.AllowedMergeMethods, ",") != "SQUASH,REBASE" {
|
||||||
|
t.Fatalf("unexpected merge metadata: %#v", got)
|
||||||
|
}
|
||||||
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
|
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
|
||||||
got.Threads[0].DiffSide != "RIGHT" || !got.Threads[0].IsTruncated {
|
got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
|
||||||
t.Fatalf("unexpected thread: %#v", got.Threads)
|
t.Fatalf("unexpected thread: %#v", got.Threads)
|
||||||
}
|
}
|
||||||
comment := got.Threads[0].Comments[0]
|
comment := got.Threads[0].Comments[0]
|
||||||
@@ -127,4 +572,56 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
|||||||
comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
|
comment.OriginalCommitOID != "0123456789abcdef" || !comment.Outdated {
|
||||||
t.Fatalf("unexpected comment snapshot: %#v", comment)
|
t.Fatalf("unexpected comment snapshot: %#v", comment)
|
||||||
}
|
}
|
||||||
|
if len(comment.Reactions) != 2 ||
|
||||||
|
comment.Reactions[0] != (ReactionSummary{Content: "THUMBS_UP", Count: 3, ViewerHasReacted: true}) ||
|
||||||
|
comment.Reactions[1] != (ReactionSummary{Content: "EYES", Count: 1}) {
|
||||||
|
t.Fatalf("unexpected comment reactions: %#v", comment.Reactions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPullRequestLoadsConflictFilesForConflictingPR(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||||
|
"url":"https://github.com/o/r","pullRequest":{
|
||||||
|
"id":"pr","number":3,"title":"Conflict","url":"u",
|
||||||
|
"mergeable":"CONFLICTING","baseRefName":"main","headRefName":"feature",
|
||||||
|
"headRefOid":"head123","baseRef":{"target":{"oid":"base123"}},
|
||||||
|
"comments":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"reviews":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"timelineItems":{"pageInfo":{"hasNextPage":false}},
|
||||||
|
"reviewThreads":{"pageInfo":{"hasNextPage":false}}
|
||||||
|
}
|
||||||
|
}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
client.conflicts = func(
|
||||||
|
_ context.Context,
|
||||||
|
repositoryURL string,
|
||||||
|
number int,
|
||||||
|
baseRef, baseOID, headOID, token string,
|
||||||
|
) ([]string, error) {
|
||||||
|
if repositoryURL != "https://github.com/o/r" || number != 3 ||
|
||||||
|
baseRef != "main" || baseOID != "base123" || headOID != "head123" ||
|
||||||
|
token != "secret" {
|
||||||
|
t.Fatalf(
|
||||||
|
"conflict loader arguments = %q, %d, %q, %q, %q, %q",
|
||||||
|
repositoryURL, number, baseRef, baseOID, headOID, token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return []string{"src/conflict.go", "README.md"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.GetPullRequest(context.Background(), "o", "r", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.ConflictFiles) != 0 {
|
||||||
|
t.Fatalf("core refresh loaded conflict files eagerly: %#v", got.ConflictFiles)
|
||||||
|
}
|
||||||
|
enrichment := client.EnrichPullRequest(context.Background(), got)
|
||||||
|
if !reflect.DeepEqual(enrichment.ConflictFiles, []string{"src/conflict.go", "README.md"}) {
|
||||||
|
t.Fatalf("conflict files = %#v", enrichment.ConflictFiles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.pablu.de/Pablu/gh-threads
|
module git.pablu.de/Pablu/diple
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
157
health.go
Normal file
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
|
const reviewContextLines = 3
|
||||||
|
|
||||||
var codeHighlightTheme = "github-dark"
|
var codeHighlightTheme = "github-dark"
|
||||||
|
var colorEnabled = true
|
||||||
|
|
||||||
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
|
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
|
||||||
|
|
||||||
@@ -53,15 +54,27 @@ func highlightDiff(path, hunk string, startLine, endLine int, side string) []hig
|
|||||||
out := make([]highlightedDiffLine, 0, len(visible))
|
out := make([]highlightedDiffLine, 0, len(visible))
|
||||||
for _, line := range visible {
|
for _, line := range visible {
|
||||||
if line.raw == "⋯" {
|
if line.raw == "⋯" {
|
||||||
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m⋯\x1b[0m"})
|
code := "⋯"
|
||||||
|
if colorEnabled {
|
||||||
|
code = "\x1b[38;5;245m⋯\x1b[0m"
|
||||||
|
}
|
||||||
|
out = append(out, highlightedDiffLine{code: code})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if line.notice {
|
if line.notice {
|
||||||
out = append(out, highlightedDiffLine{code: "\x1b[38;5;245m" + line.raw + "\x1b[0m"})
|
code := line.raw
|
||||||
|
if colorEnabled {
|
||||||
|
code = "\x1b[38;5;245m" + line.raw + "\x1b[0m"
|
||||||
|
}
|
||||||
|
out = append(out, highlightedDiffLine{code: code})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if line.header {
|
if line.header {
|
||||||
out = append(out, highlightedDiffLine{code: "\x1b[38;5;141m" + line.raw + "\x1b[0m"})
|
code := line.raw
|
||||||
|
if colorEnabled {
|
||||||
|
code = "\x1b[38;5;141m" + line.raw + "\x1b[0m"
|
||||||
|
}
|
||||||
|
out = append(out, highlightedDiffLine{code: code})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, renderDiffLine(lexer, line, padding))
|
out = append(out, renderDiffLine(lexer, line, padding))
|
||||||
@@ -176,11 +189,15 @@ func renderDiffLine(lexer string, line parsedDiffLine, padding int) highlightedD
|
|||||||
|
|
||||||
source = strings.ReplaceAll(source, "\t", " ")
|
source = strings.ReplaceAll(source, "\t", " ")
|
||||||
source = trimIndent(source, padding)
|
source = trimIndent(source, padding)
|
||||||
return highlightedDiffLine{
|
gutter := fmt.Sprintf("%5s %s ", lineNumber, marker)
|
||||||
gutter: fmt.Sprintf(
|
if colorEnabled {
|
||||||
|
gutter = fmt.Sprintf(
|
||||||
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
|
"\x1b[38;5;245m%5s\x1b[0m %s%s\x1b[0m ",
|
||||||
lineNumber, markerStyle, marker,
|
lineNumber, markerStyle, marker,
|
||||||
),
|
)
|
||||||
|
}
|
||||||
|
return highlightedDiffLine{
|
||||||
|
gutter: gutter,
|
||||||
code: highlightedSource(lexer, source),
|
code: highlightedSource(lexer, source),
|
||||||
selected: line.selected,
|
selected: line.selected,
|
||||||
}
|
}
|
||||||
@@ -224,6 +241,9 @@ func coordinateText(line int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func highlightedSource(lexer, source string) string {
|
func highlightedSource(lexer, source string) string {
|
||||||
|
if !colorEnabled {
|
||||||
|
return source
|
||||||
|
}
|
||||||
var highlighted bytes.Buffer
|
var highlighted bytes.Buffer
|
||||||
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
|
if err := quick.Highlight(&highlighted, source, lexer, "terminal16m", codeHighlightTheme); err != nil {
|
||||||
return source
|
return source
|
||||||
|
|||||||
698
keybindings.go
Normal file
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"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -20,19 +21,29 @@ func main() {
|
|||||||
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
|
repo = flag.String("repo", "", "optional GitHub repository filter as owner/name (or GH_REPO)")
|
||||||
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
|
poll = flag.Duration("poll", defaults.RefreshInterval.Duration, "refresh interval")
|
||||||
showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you")
|
showAll = flag.Bool("all", defaults.ShowAll, "show all open PRs in --repo, not only PRs assigned to you")
|
||||||
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-100)")
|
limit = flag.Int("limit", defaults.Limit, "maximum open PRs to load (1-1000)")
|
||||||
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint")
|
endpoint = flag.String("endpoint", defaults.Endpoint, "GitHub GraphQL endpoint")
|
||||||
theme = flag.String("theme", defaults.Theme, "color theme: dark or light")
|
theme = flag.String("theme", defaults.Theme, "color theme: dark, light, high-contrast, or no-color")
|
||||||
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
|
foldResolved = flag.Bool("fold-resolved", defaults.Display.FoldResolved, "start resolved threads folded")
|
||||||
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
|
listWidth = flag.Int("thread-list-width", defaults.Display.ThreadListWidthPercent, "thread list width as terminal percentage (20-60)")
|
||||||
|
dashboardMode = flag.String("dashboard-mode", defaults.Display.DashboardMode, "dashboard navigation: intermediate or hotkey")
|
||||||
|
compactReviews = flag.Bool("compact-reviews", defaults.Display.CompactReviews, "aggregate submitted review history")
|
||||||
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
|
pathScroll = flag.Bool("path-scroll", defaults.Paths.Scroll, "scroll truncated paths")
|
||||||
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
|
pathScrollRate = flag.Duration("path-scroll-interval", defaults.Paths.ScrollInterval.Duration, "path scrolling interval")
|
||||||
|
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable local read cache fallback")
|
||||||
|
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "maximum offline cache age (0 disables expiry)")
|
||||||
|
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read cache directory")
|
||||||
|
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode: standard or vim")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
visited := map[string]bool{}
|
visited := map[string]bool{}
|
||||||
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
|
flag.Visit(func(item *flag.Flag) { visited[item.Name] = true })
|
||||||
config, err := loadConfig(*configFile, visited["config"] || os.Getenv("GH_THREADS_CONFIG") != "")
|
config, err := loadConfig(
|
||||||
|
*configFile,
|
||||||
|
visited["config"] || os.Getenv("DIPLE_CONFIG") != "" ||
|
||||||
|
os.Getenv("GH_THREADS_CONFIG") != "",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitf("configuration: %v", err)
|
exitf("configuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -62,12 +73,30 @@ func main() {
|
|||||||
if visited["thread-list-width"] {
|
if visited["thread-list-width"] {
|
||||||
config.Display.ThreadListWidthPercent = *listWidth
|
config.Display.ThreadListWidthPercent = *listWidth
|
||||||
}
|
}
|
||||||
|
if visited["dashboard-mode"] {
|
||||||
|
config.Display.DashboardMode = *dashboardMode
|
||||||
|
}
|
||||||
|
if visited["compact-reviews"] {
|
||||||
|
config.Display.CompactReviews = *compactReviews
|
||||||
|
}
|
||||||
if visited["path-scroll"] {
|
if visited["path-scroll"] {
|
||||||
config.Paths.Scroll = *pathScroll
|
config.Paths.Scroll = *pathScroll
|
||||||
}
|
}
|
||||||
if visited["path-scroll-interval"] {
|
if visited["path-scroll-interval"] {
|
||||||
config.Paths.ScrollInterval.Duration = *pathScrollRate
|
config.Paths.ScrollInterval.Duration = *pathScrollRate
|
||||||
}
|
}
|
||||||
|
if visited["cache"] {
|
||||||
|
config.Cache.Enabled = *cacheEnabled
|
||||||
|
}
|
||||||
|
if visited["cache-max-age"] {
|
||||||
|
config.Cache.MaxAge.Duration = *cacheMaxAge
|
||||||
|
}
|
||||||
|
if visited["cache-dir"] {
|
||||||
|
config.Cache.Directory = *cacheDir
|
||||||
|
}
|
||||||
|
if visited["editor-mode"] {
|
||||||
|
config.Editing.Mode = *editorMode
|
||||||
|
}
|
||||||
if err := validateConfig(config); err != nil {
|
if err := validateConfig(config); err != nil {
|
||||||
exitf("configuration: %v", err)
|
exitf("configuration: %v", err)
|
||||||
}
|
}
|
||||||
@@ -89,18 +118,45 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := NewGitHubClient(config.Endpoint, token)
|
client := NewGitHubClient(config.Endpoint, token)
|
||||||
|
var service GitHubService = client
|
||||||
|
if config.Cache.Enabled {
|
||||||
|
cacheDir := config.Cache.Directory
|
||||||
|
if cacheDir == "" {
|
||||||
|
cacheDir, err = defaultCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
exitf("configuration: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service = NewCachedGitHubService(
|
||||||
|
client, cacheDir, config.Cache.MaxAge.Duration, config.Cache.MaxEntries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
statePath := filepath.Join(filepath.Dir(*configFile), "state.json")
|
||||||
|
draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json")
|
||||||
app := NewAppWithSettings(
|
app := NewAppWithSettings(
|
||||||
client, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
service, owner, name, config.ShowAll, config.Limit, config.RefreshInterval.Duration,
|
||||||
AppSettings{
|
AppSettings{
|
||||||
FoldResolved: config.Display.FoldResolved,
|
FoldResolved: config.Display.FoldResolved,
|
||||||
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
ThreadListWidthPercent: config.Display.ThreadListWidthPercent,
|
||||||
|
DashboardMode: config.Display.DashboardMode,
|
||||||
|
CompactReviews: config.Display.CompactReviews,
|
||||||
|
ReadState: loadReadState(statePath),
|
||||||
|
Drafts: loadDraftStore(draftPath),
|
||||||
PathScroll: config.Paths.Scroll,
|
PathScroll: config.Paths.Scroll,
|
||||||
PathScrollInterval: config.Paths.ScrollInterval.Duration,
|
PathScrollInterval: config.Paths.ScrollInterval.Duration,
|
||||||
ThreadStatusOrder: config.Threads.StatusOrder,
|
ThreadStatusOrder: config.Threads.StatusOrder,
|
||||||
ThreadWithinStatus: config.Threads.WithinStatus,
|
ThreadWithinStatus: config.Threads.WithinStatus,
|
||||||
|
EditorMode: config.Editing.Mode,
|
||||||
|
KeyBindings: config.KeyBindings,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil {
|
cursorOutput := newTerminalCursorOutput(os.Stdout)
|
||||||
|
app.cursorOutput = cursorOutput
|
||||||
|
if _, err := tea.NewProgram(
|
||||||
|
app,
|
||||||
|
tea.WithAltScreen(),
|
||||||
|
tea.WithOutput(cursorOutput),
|
||||||
|
).Run(); err != nil {
|
||||||
exitf("run TUI: %v", err)
|
exitf("run TUI: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,9 +171,15 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func exitf(format string, args ...any) {
|
func exitf(format string, args ...any) {
|
||||||
fmt.Fprintf(os.Stderr, "gh-threads: "+format+"\n", args...)
|
fmt.Fprintf(os.Stderr, "diple: "+format+"\n", args...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep interface drift visible at compile time.
|
// Keep interface drift visible at compile time.
|
||||||
var _ GitHubService = (*GitHubClient)(nil)
|
var _ GitHubService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubMergeService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubMergeService = (*CachedGitHubService)(nil)
|
||||||
|
var _ GitHubWriteService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
||||||
|
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
|
|||||||
style := styles.DarkStyleConfig
|
style := styles.DarkStyleConfig
|
||||||
if markdownStyleName == "light" {
|
if markdownStyleName == "light" {
|
||||||
style = styles.LightStyleConfig
|
style = styles.LightStyleConfig
|
||||||
|
} else if markdownStyleName == "notty" {
|
||||||
|
style = styles.NoTTYStyleConfig
|
||||||
}
|
}
|
||||||
zero := uint(0)
|
zero := uint(0)
|
||||||
style.Document.Margin = &zero
|
style.Document.Margin = &zero
|
||||||
|
|||||||
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"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var currentThemeName = "dark"
|
||||||
|
|
||||||
func applyTheme(name string) error {
|
func applyTheme(name string) error {
|
||||||
|
colorEnabled = true
|
||||||
switch name {
|
switch name {
|
||||||
case "dark":
|
case "dark":
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
|
||||||
@@ -15,6 +18,9 @@ func applyTheme(name string) error {
|
|||||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
|
||||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
|
||||||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
|
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
|
||||||
|
editorLineStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#D7DAE8")).
|
||||||
|
Background(lipgloss.Color("#2C3045"))
|
||||||
paneInactiveColor = lipgloss.Color("#50566F")
|
paneInactiveColor = lipgloss.Color("#50566F")
|
||||||
paneActiveColor = lipgloss.Color("#F0B72F")
|
paneActiveColor = lipgloss.Color("#F0B72F")
|
||||||
authorPalette = []lipgloss.Color{
|
authorPalette = []lipgloss.Color{
|
||||||
@@ -36,6 +42,9 @@ func applyTheme(name string) error {
|
|||||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
|
||||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
|
||||||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
|
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
|
||||||
|
editorLineStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#24292F")).
|
||||||
|
Background(lipgloss.Color("#DDE8FF"))
|
||||||
paneInactiveColor = lipgloss.Color("#8C959F")
|
paneInactiveColor = lipgloss.Color("#8C959F")
|
||||||
paneActiveColor = lipgloss.Color("#0969DA")
|
paneActiveColor = lipgloss.Color("#0969DA")
|
||||||
authorPalette = []lipgloss.Color{
|
authorPalette = []lipgloss.Color{
|
||||||
@@ -50,9 +59,40 @@ func applyTheme(name string) error {
|
|||||||
suggestionAddBackground = "\x1b[48;5;194m"
|
suggestionAddBackground = "\x1b[48;5;194m"
|
||||||
changedRemoveBackground = "\x1b[48;2;255;170;170m"
|
changedRemoveBackground = "\x1b[48;2;255;170;170m"
|
||||||
changedAddBackground = "\x1b[48;2;170;230;170m"
|
changedAddBackground = "\x1b[48;2;170;230;170m"
|
||||||
|
case "high-contrast":
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#FFFF00"))
|
||||||
|
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
|
||||||
|
activeStyle = lipgloss.NewStyle().Bold(true).Reverse(true)
|
||||||
|
okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00"))
|
||||||
|
warnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFF00"))
|
||||||
|
badStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555"))
|
||||||
|
editorLineStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FFFFFF")).
|
||||||
|
Reverse(true)
|
||||||
|
paneInactiveColor, paneActiveColor = lipgloss.Color("#FFFFFF"), lipgloss.Color("#FFFF00")
|
||||||
|
authorPalette = []lipgloss.Color{"#00FFFF", "#FF55FF", "#FFFF00", "#00FF00", "#FFFFFF"}
|
||||||
|
codeHighlightTheme, markdownStyleName = "github-dark", "dark"
|
||||||
|
quoteRailStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
|
||||||
|
selectedLineBackground = "\x1b[7m"
|
||||||
|
suggestionRemoveBackground, suggestionAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
|
||||||
|
changedRemoveBackground, changedAddBackground = "\x1b[48;5;88m", "\x1b[48;5;28m"
|
||||||
|
case "no-color":
|
||||||
|
colorEnabled = false
|
||||||
|
titleStyle = lipgloss.NewStyle()
|
||||||
|
dimStyle = lipgloss.NewStyle()
|
||||||
|
activeStyle = lipgloss.NewStyle().Reverse(true)
|
||||||
|
okStyle, warnStyle, badStyle = lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
|
||||||
|
editorLineStyle = lipgloss.NewStyle().Reverse(true)
|
||||||
|
paneInactiveColor, paneActiveColor = "", ""
|
||||||
|
authorPalette = []lipgloss.Color{""}
|
||||||
|
codeHighlightTheme, markdownStyleName = "github", "notty"
|
||||||
|
quoteRailStyle = lipgloss.NewStyle()
|
||||||
|
selectedLineBackground, suggestionRemoveBackground, suggestionAddBackground = "", "", ""
|
||||||
|
changedRemoveBackground, changedAddBackground = "", ""
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown theme %q", name)
|
return fmt.Errorf("unknown theme %q", name)
|
||||||
}
|
}
|
||||||
|
currentThemeName = name
|
||||||
commentMarkdownRenderers.Clear()
|
commentMarkdownRenderers.Clear()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -18,3 +21,14 @@ func TestApplyThemeChangesAllRenderingThemes(t *testing.T) {
|
|||||||
t.Fatal("unknown theme was accepted")
|
t.Fatal("unknown theme was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
|
||||||
|
defer applyTheme("dark")
|
||||||
|
if err := applyTheme("no-color"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rendered := highlightedSource("go", "func main() {}")
|
||||||
|
if strings.Contains(rendered, "\x1b[") || colorEnabled {
|
||||||
|
t.Fatalf("no-color source contains terminal colors: %q", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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
|
UpdatedAt time.Time
|
||||||
ReviewCount int
|
ReviewCount int
|
||||||
ViewerAuthored bool
|
ViewerAuthored bool
|
||||||
|
FromCache bool
|
||||||
|
CachedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PRDetails struct {
|
type PRDetails struct {
|
||||||
PullRequest
|
PullRequest
|
||||||
Body string
|
Body string
|
||||||
BaseRef string
|
CreatedAt time.Time
|
||||||
HeadRef string
|
BaseRef string
|
||||||
Mergeable string
|
HeadRef string
|
||||||
Assignees []string
|
Mergeable string
|
||||||
Reviewers []Reviewer
|
MergeState string
|
||||||
CheckState string
|
State string
|
||||||
ReviewDecision string
|
Merged bool
|
||||||
Threads []ReviewThread
|
MergedAt time.Time
|
||||||
ThreadsTruncated bool
|
AutoMerge *AutoMergeRequest
|
||||||
|
AllowedMergeMethods []string
|
||||||
|
ConflictFiles []string
|
||||||
|
ConflictFileError string
|
||||||
|
Assignees []string
|
||||||
|
Reviewers []Reviewer
|
||||||
|
Labels []string
|
||||||
|
Milestone string
|
||||||
|
Additions int
|
||||||
|
Deletions int
|
||||||
|
ChangedFiles int
|
||||||
|
CommitCount int
|
||||||
|
CommentCount int
|
||||||
|
HeadOID string
|
||||||
|
BaseOID string
|
||||||
|
RepositoryURL string
|
||||||
|
CheckState string
|
||||||
|
Checks []Check
|
||||||
|
ReviewDecision string
|
||||||
|
Conversation []PRComment
|
||||||
|
Reviews []ReviewSummary
|
||||||
|
Permissions ViewerPermissions
|
||||||
|
Requirements MergeRequirements
|
||||||
|
Threads []ReviewThread
|
||||||
|
ThreadsTruncated bool
|
||||||
|
Timeline []TimelineEvent
|
||||||
|
DataIssues []DataIssue
|
||||||
|
Rulesets []Ruleset
|
||||||
|
MergeQueue *MergeQueue
|
||||||
|
FromCache bool
|
||||||
|
CachedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataIssue struct {
|
||||||
|
Component string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PRDetailsEnrichment struct {
|
||||||
|
Owner string
|
||||||
|
Repository string
|
||||||
|
Number int
|
||||||
|
HeadOID string
|
||||||
|
CheckAnnotations map[string][]CheckAnnotation
|
||||||
|
ConflictFiles []string
|
||||||
|
Issues []DataIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequestMetadata struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
BaseRef string
|
||||||
|
Mergeable string
|
||||||
|
MergeState string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoMergeRequest struct {
|
||||||
|
MergeMethod string
|
||||||
|
EnabledBy string
|
||||||
|
EnabledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequestMergeResult struct {
|
||||||
|
Merged bool
|
||||||
|
MergedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryBranch struct {
|
||||||
|
Name string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
IsDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Check struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
Conclusion string
|
||||||
|
URL string
|
||||||
|
Summary string
|
||||||
|
Annotations []CheckAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckAnnotation struct {
|
||||||
|
Path string
|
||||||
|
StartLine int
|
||||||
|
EndLine int
|
||||||
|
Level string
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineEvent struct {
|
||||||
|
Kind string
|
||||||
|
OID string
|
||||||
|
BeforeOID string
|
||||||
|
AfterOID string
|
||||||
|
Author string
|
||||||
|
Title string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ruleset struct {
|
||||||
|
Name string
|
||||||
|
Enforcement string
|
||||||
|
RuleTypes []string
|
||||||
|
Applies bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type MergeQueue struct {
|
||||||
|
State string
|
||||||
|
Position int
|
||||||
|
EnqueuedAt time.Time
|
||||||
|
EstimatedSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PRComment struct {
|
||||||
|
ID string
|
||||||
|
Author string
|
||||||
|
Body string
|
||||||
|
URL string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewSummary struct {
|
||||||
|
ID string
|
||||||
|
Author string
|
||||||
|
Body string
|
||||||
|
State string
|
||||||
|
URL string
|
||||||
|
CommitOID string
|
||||||
|
SubmittedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewerPermissions struct {
|
||||||
|
Repository string
|
||||||
|
CanUpdatePR bool
|
||||||
|
CanResolveAny bool
|
||||||
|
CanUnresolveAny bool
|
||||||
|
CanReplyAny bool
|
||||||
|
CanReact bool
|
||||||
|
CanSubscribe bool
|
||||||
|
CanEnableMerge bool
|
||||||
|
CanDisableMerge bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type MergeRequirements struct {
|
||||||
|
ApprovalsRequired int
|
||||||
|
RequiresApprovals bool
|
||||||
|
RequiresStatusChecks bool
|
||||||
|
RequiresConversation bool
|
||||||
|
RequiresCodeOwnerReview bool
|
||||||
|
RequiresDeployments bool
|
||||||
|
RequiredDeployments []string
|
||||||
|
RequiresStrictChecks bool
|
||||||
|
RequiresLinearHistory bool
|
||||||
|
RequiresSignatures bool
|
||||||
|
RequiresMergeQueue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Reviewer struct {
|
type Reviewer struct {
|
||||||
@@ -37,15 +197,18 @@ type Reviewer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ReviewThread struct {
|
type ReviewThread struct {
|
||||||
ID string
|
ID string
|
||||||
Path string
|
Path string
|
||||||
Line int
|
Line int
|
||||||
StartLine int
|
StartLine int
|
||||||
DiffSide string
|
DiffSide string
|
||||||
IsResolved bool
|
IsResolved bool
|
||||||
IsOutdated bool
|
IsOutdated bool
|
||||||
IsTruncated bool
|
IsTruncated bool
|
||||||
Comments []ReviewComment
|
ViewerCanResolve bool
|
||||||
|
ViewerCanUnresolve bool
|
||||||
|
ViewerCanReply bool
|
||||||
|
Comments []ReviewComment
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewComment struct {
|
type ReviewComment struct {
|
||||||
@@ -61,4 +224,11 @@ type ReviewComment struct {
|
|||||||
Outdated bool
|
Outdated bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
URL string
|
URL string
|
||||||
|
Reactions []ReactionSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionSummary struct {
|
||||||
|
Content string
|
||||||
|
Count int
|
||||||
|
ViewerHasReacted bool
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user