Make dashboard updateable

This commit is contained in:
2026-07-28 12:44:31 +02:00
parent 72e1eb1fda
commit bb8e91039f
21 changed files with 3482 additions and 43 deletions

View File

@@ -97,6 +97,9 @@ within_status = "file" # "file" or "timestamp" (oldest first)
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
[editing]
mode = "vim" # "vim" or "standard"; description field only for now
```
When cached data exists, the picker and PR details are rendered immediately
@@ -125,7 +128,7 @@ configured repository when `--repo` is not provided. The corresponding flags
include `--config`, `--theme`, `--poll`, `--fold-resolved`,
`--thread-list-width`, `--dashboard-mode`, `--compact-reviews`,
`--path-scroll`, and `--path-scroll-interval`, plus `--cache`,
`--cache-max-age`, and `--cache-dir`.
`--cache-max-age`, `--cache-dir`, and `--editor-mode`.
Boolean settings can be disabled explicitly, for
example `--compact-reviews=false`.
@@ -147,6 +150,7 @@ history and metadata.
| `j` / `k` | Move between items or scroll the dashboard/focused detail |
| `?` | Show contextual keybinding help |
| `d` | Open the current pull request dashboard |
| `e` | Edit the current PR title, target branch, and description from its dashboard |
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
| `F` | Clear active thread filters |
| `n` / `N` | Next / previous thread with a new update |
@@ -168,15 +172,50 @@ 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 per-thread capability.
GitHub does not grant the corresponding capability.
The dashboard editor works with raw Markdown so template checklists can be
updated directly. The active line is highlighted without inserting a
layout-changing block character. It opens with the description focused;
`tab` and `shift-tab` move between the description, title, and target branch.
When the target branch is focused, repository branches are recommended using
the typed text, likely branch names, the current/default branch, and each
branch's latest commit time. The list updates as you type. Use `ctrl-p` and
`ctrl-n` to select the previous or next result, then `tab` or `enter` to complete it;
pressing `tab` again moves to the description.
With the default `editing.mode = "vim"`, the description starts in Normal mode.
It supports `hjkl`, `0`, `^`, `$`, `gg`, `G`, `w`/`W`, `b`/`B`, `e`/`E`,
`f`/`F`/`t`/`T` with `;` and `,`, `i`/`a`/`I`/`A`, `o`/`O`, `s`, and
`x`/`X`. `s` removes the character under the cursor and enters Insert mode.
Soft-wrapped rows behave as visual editor lines for vertical and line-local
motions, but do not add newlines to the Markdown submitted to GitHub.
`ctrl-d` and `ctrl-u` move the cursor and viewport down or up by half a page,
including while extending a Visual selection.
`v` starts character-wise Visual mode and `V` starts visual-line selection;
`d` or `x` deletes the selection, `y` copies it to the system clipboard, and
`p` pastes from the system clipboard. Normal mode uses a block cursor, while
Insert mode uses the terminal's hardware bar cursor at the boundary between
characters without hiding or shifting either character.
The description retains its raw Markdown while headings, emphasis, inline
code, links, quote markers, and HTML comments receive syntax highlighting.
Highlighting consists only of zero-width terminal styling and cannot alter
wrapping, selection, clipboard contents, cursor offsets, or submitted text.
`esc` returns from Insert to Normal mode; a second `esc` cancels the editor.
Set `editing.mode = "standard"` for direct insertion with arrow,
`home`, and `end` navigation. Title and target branch remain standard inputs
in either mode. `ctrl-s` opens an explicit confirmation. If the title,
description, or target branch changes remotely while the editor is open,
submission is blocked rather than overwriting the newer metadata.
## Current scope
The application can reply to review threads and resolve or unresolve them.
Other write operations remain disabled. The dashboard shows the capability
gate, including why each action is unavailable. Read state
The application can reply to review threads, resolve or unresolve them, and
update the PR title, target branch, and description. 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
fallback. Check contexts and annotations are paginated. GitHub features which
depend on server-side context, such as unfurling issue references or displaying
uploaded images, are represented textually in the terminal. See
[`TODO.md`](TODO.md) for remaining read-only work and write-support preparation.

View File

@@ -31,6 +31,9 @@ The following read-only improvements remain before or alongside write support.
## Preparation for write support
- Add dashboard selectors for requested reviewers, labels, and assignees. Each
selector must support adding, removing, and clearing values with explicit
confirmation.
- Fetch per-comment update/delete permissions.
- Define head-SHA conflict handling for comments and suggestions composed
against an older revision.

205
branch_completion.go Normal file
View File

@@ -0,0 +1,205 @@
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(" ctrl-p/n choose • tab/enter complete • tab again advances")}
now := time.Now()
for index, suggestion := range suggestions {
prefix := " "
if index == clamp(m.prEditBranchIndex, 0, len(suggestions)-1) {
prefix = " ▶ "
}
suffix := branchAgeLabel(suggestion.branch.UpdatedAt, now)
if suggestion.branch.IsDefault {
suffix = "default • " + suffix
}
if suggestion.branch.Name == m.prEditOriginal.BaseRef {
suffix = "current • " + suffix
}
available := max(1, width-len([]rune(prefix))-len([]rune(suffix))-2)
name := ansi.Truncate(suggestion.branch.Name, available, "…")
line := prefix + name + strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) + dimStyle.Render(suffix)
if strings.HasPrefix(prefix, " ▶") {
line = titleStyle.Render(prefix+name) +
strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) +
dimStyle.Render(suffix)
}
lines = append(lines, line)
}
return lines
}

104
branch_completion_test.go Normal file
View File

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

View File

@@ -138,6 +138,28 @@ func (c *CachedGitHubService) ReplyToThread(
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) 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")
}
return service.ListBranches(ctx, owner, repo)
}
func (c *CachedGitHubService) pullRequestsKey(owner, repo string, limit int, showAll bool) string {
return fmt.Sprintf("prs:%s/%s:%d:%t", owner, repo, limit, showAll)
}

View File

@@ -35,6 +35,7 @@ type Config struct {
Paths PathConfig `toml:"paths"`
Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"`
}
type DisplayConfig struct {
@@ -60,6 +61,10 @@ type CacheConfig struct {
Directory string `toml:"directory"`
}
type EditingConfig struct {
Mode string `toml:"mode"`
}
func defaultConfig() Config {
return Config{
Theme: "dark",
@@ -80,7 +85,8 @@ func defaultConfig() Config {
StatusOrder: []string{"unresolved", "outdated", "resolved"},
WithinStatus: "file",
},
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}},
Editing: EditingConfig{Mode: "vim"},
}
}
@@ -170,6 +176,11 @@ func validateConfig(config Config) error {
if config.Cache.MaxAge.Duration < 0 {
return fmt.Errorf("cache.max_age must not be negative")
}
switch config.Editing.Mode {
case "standard", "vim":
default:
return fmt.Errorf("editing.mode must be standard or vim")
}
return nil
}

View File

@@ -19,7 +19,8 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
got.Paths.Scroll != want.Paths.Scroll ||
got.Display.FoldResolved != want.Display.FoldResolved ||
got.Display.CompactReviews != want.Display.CompactReviews {
got.Display.CompactReviews != want.Display.CompactReviews ||
got.Editing.Mode != "vim" {
t.Fatalf("defaults = %#v, want %#v", got, want)
}
}
@@ -52,6 +53,9 @@ within_status = "timestamp"
enabled = false
max_age = "48h"
directory = "/tmp/gh-threads-cache"
[editing]
mode = "standard"
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
@@ -71,7 +75,8 @@ directory = "/tmp/gh-threads-cache"
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" {
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" ||
got.Editing.Mode != "standard" {
t.Fatalf("config = %#v", got)
}
}
@@ -164,3 +169,11 @@ func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
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")
}
}

129
github.go
View File

@@ -25,6 +25,14 @@ type GitHubWriteService interface {
ReplyToThread(context.Context, string, string) (ReviewComment, error)
}
type GitHubPullRequestWriteService interface {
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
}
type GitHubBranchService interface {
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
}
type GitHubClient struct {
endpoint string
token string
@@ -121,6 +129,84 @@ query PullRequests($query: String!, $first: Int!, $after: String) {
}
}`
const repositoryBranchesQuery = `
query RepositoryBranches($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
defaultBranchRef { name }
refs(refPrefix: "refs/heads/", first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
name
target {
... on Commit { committedDate }
}
}
}
}
}`
func (c *GitHubClient) ListBranches(
ctx context.Context, owner, name string,
) ([]RepositoryBranch, error) {
var branches []RepositoryBranch
after := ""
defaultBranch := ""
for {
var data struct {
Repository *struct {
DefaultBranchRef *struct{ Name string }
Refs struct {
PageInfo githubPageInfo
Nodes []struct {
Name string
Target *struct{ CommittedDate time.Time }
}
}
}
}
if err := c.query(ctx, repositoryBranchesQuery, map[string]any{
"owner": owner,
"name": name,
"after": nullableCursor(after),
}, &data); err != nil {
return nil, err
}
if data.Repository == nil {
return nil, fmt.Errorf("repository %s/%s was not found", owner, name)
}
if data.Repository.DefaultBranchRef != nil {
defaultBranch = data.Repository.DefaultBranchRef.Name
}
for _, node := range data.Repository.Refs.Nodes {
if node.Name == "" {
continue
}
branch := RepositoryBranch{Name: node.Name, IsDefault: node.Name == defaultBranch}
if node.Target != nil {
branch.UpdatedAt = node.Target.CommittedDate
}
branches = append(branches, branch)
}
if !data.Repository.Refs.PageInfo.HasNextPage {
break
}
after = data.Repository.Refs.PageInfo.EndCursor
if after == "" {
return nil, errors.New("GitHub returned an empty branch pagination cursor")
}
}
sort.SliceStable(branches, func(i, j int) bool {
if branches[i].IsDefault != branches[j].IsDefault {
return branches[i].IsDefault
}
if !branches[i].UpdatedAt.Equal(branches[j].UpdatedAt) {
return branches[i].UpdatedAt.After(branches[j].UpdatedAt)
}
return strings.ToLower(branches[i].Name) < strings.ToLower(branches[j].Name)
})
return branches, nil
}
type githubPRSearchNode struct {
ID string `json:"id"`
Number int `json:"number"`
@@ -478,6 +564,15 @@ mutation ReplyToReviewThread($input: AddPullRequestReviewThreadReplyInput!) {
}
}`
const updatePullRequestMutation = `
mutation UpdatePullRequest($input: UpdatePullRequestInput!) {
updatePullRequest(input: $input) {
pullRequest {
id title body baseRefName updatedAt mergeable mergeStateStatus
}
}
}`
type githubActor struct {
Login string `json:"login"`
Name string `json:"name"`
@@ -1190,6 +1285,40 @@ func (c *GitHubClient) ReplyToThread(
return convertReviewComment(*data.AddPullRequestReviewThreadReply.Comment), nil
}
func (c *GitHubClient) UpdatePullRequest(
ctx context.Context,
pullRequestID string,
update PullRequestMetadata,
) (PullRequestMetadata, error) {
var data struct {
UpdatePullRequest *struct {
PullRequest *struct {
ID, Title, Body, BaseRefName, Mergeable, MergeStateStatus string
UpdatedAt time.Time
}
}
}
if err := c.query(ctx, updatePullRequestMutation, map[string]any{
"input": map[string]any{
"pullRequestId": pullRequestID,
"title": update.Title,
"body": update.Body,
"baseRefName": update.BaseRef,
},
}, &data); err != nil {
return PullRequestMetadata{}, err
}
if data.UpdatePullRequest == nil || data.UpdatePullRequest.PullRequest == nil {
return PullRequestMetadata{}, errors.New("GitHub returned no updated pull request")
}
node := data.UpdatePullRequest.PullRequest
return PullRequestMetadata{
Title: node.Title, Body: node.Body, BaseRef: node.BaseRefName,
Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
UpdatedAt: node.UpdatedAt,
}, nil
}
func convertReviewThread(thread githubReviewThread) ReviewThread {
item := ReviewThread{
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,

View File

@@ -8,8 +8,62 @@ import (
"reflect"
"strings"
"testing"
"time"
)
func TestListBranchesPaginatesAndMarksTheDefaultBranch(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var request graphQLRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if !strings.Contains(request.Query, "query RepositoryBranches") {
t.Fatalf("unexpected query: %s", request.Query)
}
if requests == 1 {
if request.Variables["after"] != nil {
t.Fatalf("first cursor = %#v", request.Variables["after"])
}
_, _ = w.Write([]byte(`{"data":{"repository":{
"defaultBranchRef":{"name":"main"},
"refs":{"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[
{"name":"feature/old","target":{"committedDate":"2024-01-01T00:00:00Z"}},
{"name":"main","target":{"committedDate":"2025-01-01T00:00:00Z"}}
]}
}}}`))
return
}
if request.Variables["after"] != "next" {
t.Fatalf("second cursor = %#v", request.Variables["after"])
}
_, _ = w.Write([]byte(`{"data":{"repository":{
"defaultBranchRef":{"name":"main"},
"refs":{"pageInfo":{"hasNextPage":false},"nodes":[
{"name":"release/2.0","target":{"committedDate":"2026-07-28T00:00:00Z"}}
]}
}}}`))
}))
defer server.Close()
client := NewGitHubClient(server.URL, "secret")
branches, err := client.ListBranches(context.Background(), "o", "r")
if err != nil {
t.Fatal(err)
}
if requests != 2 || len(branches) != 3 {
t.Fatalf("requests=%d branches=%#v", requests, branches)
}
if branches[0].Name != "main" || !branches[0].IsDefault {
t.Fatalf("default branch was not first and marked: %#v", branches)
}
wantUpdated := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC)
if branches[1].Name != "release/2.0" || !branches[1].UpdatedAt.Equal(wantUpdated) {
t.Fatalf("fresh branch was not decoded and sorted: %#v", branches)
}
}
func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
@@ -176,6 +230,40 @@ func TestThreadWriteMutationsUseThreadIDs(t *testing.T) {
}
}
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 TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request graphQLRequest

15
main.go
View File

@@ -33,6 +33,7 @@ func main() {
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()
@@ -89,6 +90,9 @@ func main() {
if visited["cache-dir"] {
config.Cache.Directory = *cacheDir
}
if visited["editor-mode"] {
config.Editing.Mode = *editorMode
}
if err := validateConfig(config); err != nil {
exitf("configuration: %v", err)
}
@@ -134,9 +138,16 @@ func main() {
PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder,
ThreadWithinStatus: config.Threads.WithinStatus,
EditorMode: config.Editing.Mode,
},
)
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)
}
}
@@ -159,3 +170,5 @@ func exitf(format string, args ...any) {
var _ GitHubService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*GitHubClient)(nil)
var _ GitHubWriteService = (*CachedGitHubService)(nil)
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
var _ GitHubPullRequestWriteService = (*CachedGitHubService)(nil)

View File

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

472
pr_editor.go Normal file
View File

@@ -0,0 +1,472 @@
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
for index := range m.prEditEditors {
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
}
m.prEditOriginal = m.currentPRMetadata()
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 := key.String()
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, nil
}
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)
help := "tab/shift-tab field • ctrl-s review • esc normal/cancel"
if m.prEditEditors[prEditBodyField].Modal {
help += " • i/a/s insert • v/V select • y copy • p paste • hjkl/wWbBeE move • ctrl-d/u page • fFtT + ; find"
} else {
help += " • arrows/home/end move • ctrl-d/u page • enter newline"
}
wrapped := ansi.Hardwrap(ansi.Wordwrap(help, width, ""), width, false)
lines = append(lines, "")
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, dimStyle.Render(line))
}
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("y submit • n/esc continue editing"))
return lines
}

84
system_clipboard.go Normal file
View File

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

86
terminal_cursor.go Normal file
View File

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

52
terminal_cursor_test.go Normal file
View File

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

938
text_editor.go Normal file
View File

@@ -0,0 +1,938 @@
package main
import (
"strings"
"unicode"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
type textEditorMode string
const (
textEditorNormal textEditorMode = "NORMAL"
textEditorInsert textEditorMode = "INSERT"
textEditorVisual textEditorMode = "VISUAL"
)
type textFind struct {
command rune
target rune
valid bool
}
// textEditor owns buffer and motion state independently of any particular
// screen. Inputs can opt into modal behavior without duplicating cursor logic.
type textEditor struct {
Text string
Cursor int
Modal bool
Mode textEditorMode
pendingFind rune
pendingG bool
lastFind textFind
visualAnchor int
visualLine bool
clipboard textClipboard
err error
hardwareCursor bool
highlightMarkdown bool
}
func newTextEditor(text string, modal bool) textEditor {
editor := textEditor{
Text: text, Modal: modal, Mode: textEditorInsert,
clipboard: systemTextClipboard{},
}
editor.Cursor = len([]rune(text))
if modal {
editor.Mode = textEditorNormal
editor.Cursor = 0
}
return editor
}
func (e *textEditor) handleKey(key tea.KeyMsg, multiline bool) bool {
return e.handleKeyAtWidth(key, multiline, 0)
}
func (e *textEditor) handleKeyAtWidth(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
if !e.Modal {
return e.handleStandardKey(key, multiline, wrapWidth)
}
if e.Mode == textEditorInsert {
if key.String() == "esc" {
e.Mode = textEditorNormal
e.clearPending()
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor > start {
e.Cursor--
}
return true
}
return e.handleStandardKey(key, multiline, wrapWidth)
}
if e.Mode == textEditorVisual {
return e.handleVisualKey(key, multiline, wrapWidth)
}
return e.handleNormalKey(key, multiline, wrapWidth)
}
func (e *textEditor) movePage(delta, wrapWidth int) {
normalCursor := e.Mode != textEditorInsert
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, delta, wrapWidth, normalCursor)
e.clearPending()
}
func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
switch key.String() {
case "left":
e.Cursor = max(0, e.Cursor-1)
case "right":
e.Cursor = min(len([]rune(e.Text)), e.Cursor+1)
case "up":
if !multiline {
return false
}
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false)
case "down":
if !multiline {
return false
}
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false)
case "home", "ctrl+a":
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "end", "ctrl+e":
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "backspace":
e.deleteBefore()
case "delete":
e.deleteAt()
case "enter":
if !multiline {
return false
}
e.insert("\n")
default:
if key.Type == tea.KeyRunes {
e.insert(string(key.Runes))
} else if key.Type == tea.KeySpace {
e.insert(" ")
} else {
return false
}
}
return true
}
func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
if e.pendingFind != 0 {
if key.Type == tea.KeyRunes && len(key.Runes) > 0 {
command := e.pendingFind
e.pendingFind = 0
e.performFind(command, key.Runes[0], true, wrapWidth)
return true
}
if key.Type == tea.KeySpace {
command := e.pendingFind
e.pendingFind = 0
e.performFind(command, ' ', true, wrapWidth)
return true
}
e.pendingFind = 0
}
if e.pendingG {
e.pendingG = false
if key.String() == "g" {
e.Cursor = firstNonBlank(e.Text, 0)
return true
}
}
switch key.String() {
case "esc":
e.clearPending()
return false
case "i":
e.Mode = textEditorInsert
case "a":
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Cursor = min(end, e.Cursor+1)
e.Mode = textEditorInsert
case "I":
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
e.Mode = textEditorInsert
case "A":
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Mode = textEditorInsert
case "o":
if !multiline {
return false
}
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.insert("\n")
e.Mode = textEditorInsert
case "O":
if !multiline {
return false
}
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Cursor = start
e.insert("\n")
e.Cursor = start
e.Mode = textEditorInsert
case "h", "left":
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
e.Cursor = max(start, e.Cursor-1)
case "l", "right":
e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1)
case "j", "down":
if multiline {
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true)
}
case "k", "up":
if multiline {
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true)
}
case "0", "home":
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
case "^":
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
case "$", "end":
e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth)
case "w":
e.Cursor = nextWordStart(e.Text, e.Cursor, false)
case "W":
e.Cursor = nextWordStart(e.Text, e.Cursor, true)
case "b":
e.Cursor = previousWordStart(e.Text, e.Cursor, false)
case "B":
e.Cursor = previousWordStart(e.Text, e.Cursor, true)
case "e":
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth)
case "E":
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth)
case "g":
e.pendingG = true
case "G":
e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text)))
case "v":
e.startVisual(false)
case "V":
e.startVisual(true)
case "p":
e.pasteClipboard(false, wrapWidth)
case "s":
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor < end {
e.deleteAt()
}
e.Mode = textEditorInsert
case "f", "F", "t", "T":
e.pendingFind = []rune(key.String())[0]
case ";":
if e.lastFind.valid {
e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth)
}
case ",":
if e.lastFind.valid {
e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth)
}
case "x", "delete":
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor < end {
e.deleteAt()
}
case "X", "backspace":
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
if e.Cursor > start {
e.deleteBefore()
}
default:
return false
}
return true
}
func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
if e.pendingFind != 0 || e.pendingG {
e.Mode = textEditorNormal
handled := e.handleNormalKey(key, multiline, wrapWidth)
e.Mode = textEditorVisual
return handled
}
switch key.String() {
case "esc", "v":
e.stopVisual()
case "V":
if e.visualLine {
e.stopVisual()
} else {
e.visualLine = true
}
case "o":
e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor
case "y":
e.yankSelection(wrapWidth)
case "d", "x", "delete":
e.deleteSelection(wrapWidth)
case "p":
e.pasteClipboard(true, wrapWidth)
default:
if !isVisualMotion(key.String()) {
return false
}
e.Mode = textEditorNormal
handled := e.handleNormalKey(key, multiline, wrapWidth)
e.Mode = textEditorVisual
return handled
}
return true
}
func isVisualMotion(key string) bool {
switch key {
case "h", "j", "k", "l", "left", "down", "up", "right",
"0", "^", "$", "home", "end",
"w", "W", "b", "B", "e", "E", "g", "G",
"f", "F", "t", "T", ";", ",":
return true
default:
return false
}
}
func (e *textEditor) startVisual(linewise bool) {
e.Mode = textEditorVisual
e.visualAnchor = e.Cursor
e.visualLine = linewise
e.clearPending()
}
func (e *textEditor) stopVisual() {
e.Mode = textEditorNormal
e.visualLine = false
e.clearPending()
}
func (e textEditor) selectionBounds(wrapWidth int) (int, int, bool) {
if e.Mode != textEditorVisual {
return 0, 0, false
}
runes := []rune(e.Text)
anchor := clamp(e.visualAnchor, 0, len(runes))
cursor := clamp(e.Cursor, 0, len(runes))
if !e.visualLine {
start, end := min(anchor, cursor), min(len(runes), max(anchor, cursor)+1)
return start, end, end > start
}
anchorStart, anchorEnd := editorLineBounds(e.Text, anchor, wrapWidth)
cursorStart, cursorEnd := editorLineBounds(e.Text, cursor, wrapWidth)
start, end := min(anchorStart, cursorStart), max(anchorEnd, cursorEnd)
if end < len(runes) && runes[end] == '\n' {
end++
}
return start, end, end > start
}
func (e *textEditor) yankSelection(wrapWidth int) {
start, end, ok := e.selectionBounds(wrapWidth)
if !ok {
e.stopVisual()
return
}
if e.clipboard == nil {
e.clipboard = systemTextClipboard{}
}
if err := e.clipboard.WriteText(string([]rune(e.Text)[start:end])); err != nil {
e.err = err
return
}
e.Cursor = start
e.stopVisual()
}
func (e *textEditor) deleteSelection(wrapWidth int) {
start, end, ok := e.selectionBounds(wrapWidth)
if !ok {
e.stopVisual()
return
}
runes := []rune(e.Text)
e.Text = string(append(runes[:start], runes[end:]...))
e.Cursor = min(start, len([]rune(e.Text)))
if e.Cursor == len([]rune(e.Text)) && e.Cursor > 0 {
e.Cursor--
}
e.stopVisual()
}
func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) {
if e.clipboard == nil {
e.clipboard = systemTextClipboard{}
}
value, err := e.clipboard.ReadText()
if err != nil {
e.err = err
return
}
value = normalizeLineEndings(value)
if replaceSelection {
start, end, ok := e.selectionBounds(wrapWidth)
if ok {
runes := []rune(e.Text)
e.Text = string(append(runes[:start], runes[end:]...))
e.Cursor = start
}
e.stopVisual()
} else {
_, end := editorLineBounds(e.Text, e.Cursor, 0)
e.Cursor = min(end, e.Cursor+1)
}
e.insert(value)
if e.Cursor > 0 {
e.Cursor--
}
}
func (e *textEditor) clearPending() {
e.pendingFind = 0
e.pendingG = false
}
func (e *textEditor) insert(text string) {
value := []rune(e.Text)
insert := []rune(text)
cursor := clamp(e.Cursor, 0, len(value))
updated := make([]rune, 0, len(value)+len(insert))
updated = append(updated, value[:cursor]...)
updated = append(updated, insert...)
updated = append(updated, value[cursor:]...)
e.Text = string(updated)
e.Cursor = cursor + len(insert)
}
func (e *textEditor) deleteBefore() {
value := []rune(e.Text)
e.Cursor = clamp(e.Cursor, 0, len(value))
if e.Cursor == 0 {
return
}
value = append(value[:e.Cursor-1], value[e.Cursor:]...)
e.Cursor--
e.Text = string(value)
}
func (e *textEditor) deleteAt() {
value := []rune(e.Text)
e.Cursor = clamp(e.Cursor, 0, len(value))
if e.Cursor == len(value) {
return
}
value = append(value[:e.Cursor], value[e.Cursor+1:]...)
e.Text = string(value)
}
func (e *textEditor) performFind(command, target rune, remember bool, wrapWidth int) {
runes := []rune(e.Text)
cursor := clamp(e.Cursor, 0, len(runes))
start, end := editorLineBounds(e.Text, cursor, wrapWidth)
found := -1
switch command {
case 'f', 't':
searchStart := cursor + 1
if !remember && command == 't' {
searchStart++
}
for index := min(searchStart, end); index < end; index++ {
if runes[index] == target {
found = index
break
}
}
case 'F', 'T':
searchStart := cursor - 1
if !remember && command == 'T' {
searchStart--
}
for index := min(searchStart, end-1); index >= start; index-- {
if runes[index] == target {
found = index
break
}
}
}
if found >= 0 {
switch command {
case 't':
found = max(cursor, found-1)
case 'T':
found = min(cursor, found+1)
}
e.Cursor = found
}
if remember {
e.lastFind = textFind{command: command, target: target, valid: true}
}
}
func reverseFind(command rune) rune {
switch command {
case 'f':
return 'F'
case 'F':
return 'f'
case 't':
return 'T'
default:
return 't'
}
}
func firstNonBlank(value string, cursor int) int {
return firstNonBlankAtWidth(value, cursor, 0)
}
func firstNonBlankAtWidth(value string, cursor, wrapWidth int) int {
runes := []rune(value)
start, end := editorLineBounds(value, cursor, wrapWidth)
for start < end && unicode.IsSpace(runes[start]) {
start++
}
return start
}
func normalLineLast(value string, cursor int) int {
return normalEditorLineLast(value, cursor, 0)
}
func normalEditorLineLast(value string, cursor, wrapWidth int) int {
start, end := editorLineBounds(value, cursor, wrapWidth)
if end > start {
return end - 1
}
return start
}
func moveNormalCursorLine(value string, cursor, delta int) int {
return moveEditorCursorLine(value, cursor, delta, 0, true)
}
func nextWordStart(value string, cursor int, big bool) int {
runes := []rune(value)
cursor = clamp(cursor, 0, len(runes))
if cursor == len(runes) {
return cursor
}
if big {
for cursor < len(runes) && !unicode.IsSpace(runes[cursor]) {
cursor++
}
} else {
category := wordCategory(runes[cursor])
for cursor < len(runes) && wordCategory(runes[cursor]) == category {
cursor++
}
}
for cursor < len(runes) && unicode.IsSpace(runes[cursor]) {
cursor++
}
return cursor
}
func previousWordStart(value string, cursor int, big bool) int {
runes := []rune(value)
cursor = clamp(cursor, 0, len(runes))
if cursor == 0 {
return 0
}
cursor--
for cursor > 0 && unicode.IsSpace(runes[cursor]) {
cursor--
}
if big {
for cursor > 0 && !unicode.IsSpace(runes[cursor-1]) {
cursor--
}
return cursor
}
category := wordCategory(runes[cursor])
for cursor > 0 && wordCategory(runes[cursor-1]) == category {
cursor--
}
return cursor
}
func wordEnd(value string, cursor int, big bool) int {
return wordEndAtWidth(value, cursor, big, 0)
}
func wordEndAtWidth(value string, cursor int, big bool, wrapWidth int) int {
runes := []rune(value)
cursor = clamp(cursor, 0, len(runes))
if cursor == len(runes) {
return cursor
}
original := cursor
for {
_, end := editorLineBounds(value, cursor, wrapWidth)
if candidate, found := wordEndWithin(runes, cursor, end, big); found {
return candidate
}
next := end
if next < len(runes) && runes[next] == '\n' {
next++
}
if next >= len(runes) || next <= cursor {
return original
}
cursor = next
}
}
func wordEndWithin(runes []rune, cursor, end int, big bool) (int, bool) {
cursor = clamp(cursor, 0, min(end, len(runes)))
if cursor >= end {
return cursor, false
}
if unicode.IsSpace(runes[cursor]) {
for cursor < end && unicode.IsSpace(runes[cursor]) {
cursor++
}
if cursor >= end {
return cursor, false
}
} else if cursor+1 >= end || wordEndCategory(runes[cursor+1], big) != wordEndCategory(runes[cursor], big) {
cursor++
for cursor < end && unicode.IsSpace(runes[cursor]) {
cursor++
}
if cursor >= end {
return cursor, false
}
}
category := wordEndCategory(runes[cursor], big)
for cursor+1 < end && wordEndCategory(runes[cursor+1], big) == category {
cursor++
}
return cursor, true
}
func wordEndCategory(value rune, big bool) int {
if big {
if unicode.IsSpace(value) {
return 0
}
return 1
}
return wordCategory(value)
}
func wordCategory(value rune) int {
if unicode.IsSpace(value) {
return 0
}
if value == '_' || unicode.IsLetter(value) || unicode.IsNumber(value) {
return 1
}
return 2
}
func (e textEditor) modeLabel() string {
if !e.Modal {
return ""
}
return string(e.Mode)
}
func normalizeSingleLine(value string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(value)
}
func normalizeLineEndings(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
return strings.ReplaceAll(value, "\r", "\n")
}
type editorVisualLine struct {
text string
start, end int
logicalStart, logicalEnd int
}
type editorRenderedLine struct {
text string
active bool
}
func editorVisualLines(value string, width int) []editorVisualLine {
runes := []rune(value)
var visual []editorVisualLine
logicalStart := 0
for index := 0; index <= len(runes); index++ {
if index < len(runes) && runes[index] != '\n' {
continue
}
visual = append(visual, wrapEditorLogicalLine(runes, logicalStart, index, max(1, width))...)
logicalStart = index + 1
}
if len(visual) == 0 {
return []editorVisualLine{{}}
}
return visual
}
func editorVisualLineIndex(lines []editorVisualLine, cursor int) int {
for index, line := range lines {
if cursor >= line.start && cursor < line.end {
return index
}
if line.start == line.end && cursor == line.start {
return index
}
if cursor == line.logicalEnd && line.end == line.logicalEnd {
return index
}
}
return max(0, len(lines)-1)
}
func editorLineBounds(value string, cursor, wrapWidth int) (int, int) {
if wrapWidth <= 0 {
return textLineStart(value, cursor), textLineEnd(value, cursor)
}
lines := editorVisualLines(value, wrapWidth)
line := lines[editorVisualLineIndex(lines, clamp(cursor, 0, len([]rune(value))))]
return line.start, line.end
}
func moveEditorCursorLine(value string, cursor, delta, wrapWidth int, normal bool) int {
if wrapWidth <= 0 {
moved := moveTextCursorLine(value, cursor, delta)
if normal {
return min(moved, normalLineLast(value, moved))
}
return moved
}
runes := []rune(value)
lines := editorVisualLines(value, wrapWidth)
index := editorVisualLineIndex(lines, clamp(cursor, 0, len(runes)))
targetIndex := clamp(index+delta, 0, len(lines)-1)
if targetIndex == index {
return cursor
}
column := lipgloss.Width(string(runes[lines[index].start:clamp(cursor, lines[index].start, lines[index].end)]))
target := lines[targetIndex]
position, usedWidth := target.start, 0
for position < target.end {
runeWidth := lipgloss.Width(string(runes[position]))
if usedWidth+runeWidth > column {
break
}
usedWidth += runeWidth
position++
}
if normal && target.end > target.start {
position = min(position, target.end-1)
}
return position
}
func renderTextEditor(editor textEditor, width int, active bool) []editorRenderedLine {
width = max(1, width)
runes := []rune(editor.Text)
cursor := clamp(editor.Cursor, 0, len(runes))
visual := editorVisualLines(editor.Text, width)
activeIndex := editorVisualLineIndex(visual, cursor)
selectionStart, selectionEnd, hasSelection := editor.selectionBounds(width)
var markdownStyles []editorMarkdownStyle
if editor.highlightMarkdown {
markdownStyles = editorMarkdownStyles(editor.Text)
}
lines := make([]editorRenderedLine, 0, len(visual))
for index, line := range visual {
onVisualLine := index == activeIndex
rendered := renderEditorVisualLine(
line, cursor, editor.Mode, selectionStart, selectionEnd,
active && hasSelection, active && onVisualLine, editor.hardwareCursor,
markdownStyles, width,
)
if active && onVisualLine {
rendered = pad(rendered, width)
}
lines = append(lines, editorRenderedLine{
text: rendered,
active: active && onVisualLine,
})
}
return lines
}
func editorCursorVisualLine(editor textEditor, width int) int {
width = max(1, width)
visual := editorVisualLines(editor.Text, width)
return editorVisualLineIndex(visual, clamp(editor.Cursor, 0, len([]rune(editor.Text))))
}
func editorCursorVisualPosition(editor textEditor, width int) (int, int) {
width = max(1, width)
runes := []rune(editor.Text)
cursor := clamp(editor.Cursor, 0, len(runes))
visual := editorVisualLines(editor.Text, width)
index := editorVisualLineIndex(visual, cursor)
line := visual[index]
column := lipgloss.Width(string(runes[line.start:clamp(cursor, line.start, line.end)]))
return index, column
}
func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine {
if start == end {
return []editorVisualLine{{
start: start, end: end, logicalStart: start, logicalEnd: end,
}}
}
var lines []editorVisualLine
for offset := start; offset < end; {
next := offset
lineWidth := 0
for next < end {
runeWidth := lipgloss.Width(string(runes[next]))
if next > offset && lineWidth+runeWidth > width {
break
}
lineWidth += runeWidth
next++
if lineWidth >= width {
break
}
}
if next == offset {
next++
}
lines = append(lines, editorVisualLine{
text: string(runes[offset:next]), start: offset, end: next,
logicalStart: start, logicalEnd: end,
})
offset = next
}
return lines
}
func renderEditorVisualLine(
line editorVisualLine,
cursor int,
mode textEditorMode,
selectionStart, selectionEnd int,
hasSelection, showCursor, hardwareCursor bool,
markdownStyles []editorMarkdownStyle,
width int,
) string {
const (
reverseStart = "\x1b[7m"
reverseEnd = "\x1b[27m"
underlineStart = "\x1b[4m"
underlineEnd = "\x1b[24m"
)
runes := []rune(line.text)
var rendered strings.Builder
selected := false
markdownStyle := editorMarkdownPlain
for offset, value := range runes {
position := line.start + offset
nextMarkdownStyle := editorMarkdownPlain
if position < len(markdownStyles) {
nextMarkdownStyle = markdownStyles[position]
}
if nextMarkdownStyle != markdownStyle {
if markdownStyle != editorMarkdownPlain {
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
}
if nextMarkdownStyle != editorMarkdownPlain {
rendered.WriteString(editorMarkdownStyleStart(nextMarkdownStyle))
}
markdownStyle = nextMarkdownStyle
}
nowSelected := hasSelection && position >= selectionStart && position < selectionEnd
if nowSelected != selected {
if nowSelected {
rendered.WriteString(reverseStart)
} else {
rendered.WriteString(reverseEnd)
}
selected = nowSelected
}
if showCursor && position == cursor {
switch mode {
case textEditorInsert:
if hardwareCursor {
rendered.WriteRune(value)
continue
}
rendered.WriteString(underlineStart)
rendered.WriteRune(value)
rendered.WriteString(underlineEnd)
continue
case textEditorVisual:
rendered.WriteString(underlineStart)
rendered.WriteRune(value)
rendered.WriteString(underlineEnd)
continue
default:
if !selected {
rendered.WriteString(reverseStart)
}
rendered.WriteRune(value)
if !selected {
rendered.WriteString(reverseEnd)
}
continue
}
}
rendered.WriteRune(value)
}
if selected {
rendered.WriteString(reverseEnd)
}
if markdownStyle != editorMarkdownPlain {
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
}
if showCursor && cursor == line.end {
switch mode {
case textEditorInsert:
if hardwareCursor {
break
}
if lipgloss.Width(line.text) < width {
rendered.WriteString(underlineStart + " " + underlineEnd)
} else if len(runes) > 0 {
value := rendered.String()
rendered.Reset()
rendered.WriteString(ansi.Truncate(value, max(0, width-1), ""))
rendered.WriteString(underlineStart)
rendered.WriteRune(runes[len(runes)-1])
rendered.WriteString(underlineEnd)
}
case textEditorVisual:
rendered.WriteString(underlineStart + " " + underlineEnd)
default:
if lipgloss.Width(line.text) < width {
rendered.WriteString(reverseStart + " " + reverseEnd)
} else if len(runes) > 0 {
// A full visual row has no spare cell. Re-render its last
// character as the block cursor without adding layout width.
value := rendered.String()
rendered.Reset()
rendered.WriteString(ansi.Truncate(value, max(0, width-1), ""))
rendered.WriteString(reverseStart)
rendered.WriteRune(runes[len(runes)-1])
rendered.WriteString(reverseEnd)
}
}
}
return rendered.String()
}

507
text_editor_test.go Normal file
View File

@@ -0,0 +1,507 @@
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 (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 TestVimTextEditorNormalMotionsStayOnCharactersWithinLine(t *testing.T) {
editor := newTextEditor("ab\n cd\n", true)
editor.Cursor = 1
editor.handleKey(runeKey("l"), true)
if editor.Cursor != 1 {
t.Fatalf("l crossed line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x result = %q", editor.Text)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x deleted newline: %q", editor.Text)
}
editor.Cursor = 4
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 7 {
t.Fatalf("j cursor = %d, want empty last line at 7", editor.Cursor)
}
editor.handleKey(runeKey("h"), true)
if editor.Cursor != 7 {
t.Fatalf("h crossed from empty line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("X"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("X deleted newline: %q", editor.Text)
}
}
func TestVimTextEditorDocumentMotionsUseFirstNonBlank(t *testing.T) {
editor := newTextEditor(" first\n last", true)
editor.Cursor = 10
editor.handleKey(runeKey("g"), true)
editor.handleKey(runeKey("g"), true)
if editor.Cursor != 2 {
t.Fatalf("gg cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey("G"), true)
if editor.Cursor != 11 {
t.Fatalf("G cursor = %d, want 11", editor.Cursor)
}
}
func TestEditorHighlightsCurrentLineWithoutChangingLayout(t *testing.T) {
editor := newTextEditor("short\nsecond", true)
lines := renderTextEditor(editor, 20, true)
if len(lines) != 2 || strings.Contains(joinEditorLines(lines), "█") {
t.Fatalf("cursor changed editor layout: %#v", lines)
}
if width := ansi.StringWidth(lines[0].text); width != 20 {
t.Fatalf("active line width = %d, want 20", width)
}
if width := ansi.StringWidth(lines[1].text); width != len("second") {
t.Fatalf("inactive line width = %d", width)
}
if !lines[0].active || lines[1].active {
t.Fatalf("active rows = %#v", lines)
}
editor.handleKey(runeKey("j"), true)
lines = renderTextEditor(editor, 20, true)
if width := ansi.StringWidth(lines[0].text); width != len("short") {
t.Fatalf("old line remained highlighted at width %d", width)
}
if width := ansi.StringWidth(lines[1].text); width != 20 {
t.Fatalf("new active line width = %d, want 20", width)
}
}
func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 16
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 3 {
t.Fatalf("wrapped rows = %d, want 3", len(rendered))
}
app := App{prEditField: prEditBodyField}
app.prEditEditors[prEditBodyField] = editor
rows := app.prEditFieldLines("description", prEditBodyField, 14)
if len(rows) != 4 {
t.Fatalf("field rows = %#v", rows)
}
want := []string{"│ abcdefghij", "│ klmnopqrst", "│ uv"}
for index, expected := range want {
plain := strings.TrimRight(ansi.Strip(rows[index+1]), " ")
if plain != expected {
t.Fatalf("wrapped row %d = %q, want %q", index, plain, expected)
}
if ansi.StringWidth(rows[index+1]) > 12 {
t.Fatalf("wrapped row %d is too wide: %d", index, ansi.StringWidth(rows[index+1]))
}
}
}
func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) {
const value = "abcdefghijklmnopqrstuv"
editor := newTextEditor(value, true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 12 {
t.Fatalf("first visual j cursor = %d, want 12", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("$"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("visual $ cursor = %d, want 19", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("l crossed soft wrap at cursor %d", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 21 {
t.Fatalf("second visual j cursor = %d, want 21", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("0"), true, 10)
if editor.Cursor != 20 {
t.Fatalf("visual 0 cursor = %d, want 20", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("k"), true, 10)
if editor.Cursor != 10 {
t.Fatalf("visual k cursor = %d, want 10", editor.Cursor)
}
if editor.Text != value {
t.Fatalf("visual navigation changed stored text: %q", editor.Text)
}
rendered := renderTextEditor(editor, 10, true)
activeRows := 0
for _, line := range rendered {
if line.active {
activeRows++
}
}
if activeRows != 1 {
t.Fatalf("active visual rows = %d, want 1", activeRows)
}
}
func TestEditorDoesNotAddPhantomRowAtExactSoftWrap(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrst", true)
editor.Cursor = len([]rune(editor.Text))
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 2 {
t.Fatalf("rendered rows = %d, want 2: %#v", len(rendered), rendered)
}
if !rendered[1].active {
t.Fatalf("last wrapped row is not active: %#v", rendered)
}
}
func TestEditorLineEndingNormalizationRemovesTerminalCarriageReturns(t *testing.T) {
const mixed = "first\nsecond\r\nthird\rfourth"
normalized := normalizeLineEndings(mixed)
if normalized != "first\nsecond\nthird\nfourth" {
t.Fatalf("normalized text = %q", normalized)
}
editor := newTextEditor(normalized, true)
for _, line := range renderTextEditor(editor, 80, true) {
if strings.ContainsRune(line.text, '\r') {
t.Fatalf("rendered terminal carriage return in %#v", line)
}
}
}
func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("v"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Mode != textEditorVisual || editor.Cursor != 13 {
t.Fatalf("visual selection mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("d"), true, 10)
if editor.Text != "abopqrstuv" {
t.Fatalf("visual delete result = %q", editor.Text)
}
if editor.Mode != textEditorNormal || editor.Cursor != 2 {
t.Fatalf("after visual delete mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdef", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("y"), true)
if clipboard.written != "abc" {
t.Fatalf("yanked text = %q, want abc", clipboard.written)
}
if editor.Text != "abcdef" || editor.Mode != textEditorNormal {
t.Fatalf("yank changed editor: %#v", editor)
}
clipboard.text = "XY"
editor.Cursor = 0
editor.handleKey(runeKey("p"), true)
if editor.Text != "aXYbcdef" || editor.Cursor != 2 {
t.Fatalf("paste result text=%q cursor=%d", editor.Text, editor.Cursor)
}
}
func TestVimVisualLineYankCollapsesSoftWraps(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.clipboard = clipboard
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("V"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("y"), true, 10)
if clipboard.written != "abcdefghijklmnopqrst" {
t.Fatalf("linewise soft-wrap yank = %q", clipboard.written)
}
if strings.ContainsRune(clipboard.written, '\n') {
t.Fatalf("soft-wrap yank introduced newline: %q", clipboard.written)
}
}
func TestVimVisualFindAcceptsArbitraryTarget(t *testing.T) {
editor := newTextEditor("one x two", true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("f"), true)
editor.handleKey(runeKey("x"), true)
if editor.Mode != textEditorVisual || editor.Cursor != 4 {
t.Fatalf("visual fx mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimClipboardErrorsRemainVisibleAndPreserveSelection(t *testing.T) {
clipboard := &memoryTextClipboard{writeErr: errors.New("clipboard failed")}
editor := newTextEditor("abc", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("y"), true)
if editor.err == nil || !strings.Contains(editor.err.Error(), "clipboard failed") {
t.Fatalf("clipboard error = %v", editor.err)
}
if editor.Mode != textEditorVisual || editor.Text != "abc" {
t.Fatalf("failed yank changed selection: %#v", editor)
}
}
func TestEditorRendersModeSpecificCursorsAndVisualSelection(t *testing.T) {
editor := newTextEditor("abc", true)
normal := renderTextEditor(editor, 10, true)
if !strings.Contains(normal[0].text, "\x1b[7m") || ansi.Strip(normal[0].text) != "abc " {
t.Fatalf("normal cursor rendering = %q", normal[0].text)
}
editor.handleKey(runeKey("i"), true)
insert := renderTextEditor(editor, 10, true)
if !strings.Contains(insert[0].text, "\x1b[4m") {
t.Fatalf("insert cursor rendering = %q", insert[0].text)
}
if width := ansi.StringWidth(insert[0].text); width != 10 {
t.Fatalf("insert cursor changed row width to %d", width)
}
if plain := strings.TrimRight(ansi.Strip(insert[0].text), " "); plain != "abc" {
t.Fatalf("insert cursor hid or shifted text: %q", plain)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
visual := renderTextEditor(editor, 10, true)
if editor.modeLabel() != "VISUAL" || !strings.Contains(visual[0].text, "\x1b[7m") {
t.Fatalf("visual rendering mode=%s text=%q", editor.modeLabel(), visual[0].text)
}
}
func TestHardwareInsertCursorDoesNotAlterRenderedText(t *testing.T) {
editor := newTextEditor("abc", true)
editor.hardwareCursor = true
editor.handleKey(runeKey("i"), true)
rendered := renderTextEditor(editor, 10, true)
if plain := strings.TrimRight(ansi.Strip(rendered[0].text), " "); plain != "abc" {
t.Fatalf("hardware cursor altered text: %q", plain)
}
if strings.Contains(rendered[0].text, "\x1b[4m") {
t.Fatalf("hardware cursor retained fallback underline: %q", rendered[0].text)
}
}
func TestMarkdownHighlightingPreservesTextWidthsAndCursorIndexes(t *testing.T) {
const markdown = "# Heading\nUse `code` and [link](https://example.com)."
editor := newTextEditor(markdown, true)
editor.highlightMarkdown = true
rendered := renderTextEditor(editor, 80, false)
var lines []string
for _, line := range rendered {
lines = append(lines, line.text)
}
highlighted := strings.Join(lines, "\n")
if ansi.Strip(highlighted) != markdown {
t.Fatalf("highlighting changed text:\n%q\nwant:\n%q", ansi.Strip(highlighted), markdown)
}
if !strings.Contains(highlighted, "\x1b[") {
t.Fatalf("Markdown was not highlighted: %q", highlighted)
}
for index, line := range rendered {
if ansi.StringWidth(line.text) != ansi.StringWidth(ansi.Strip(line.text)) {
t.Fatalf("highlighted line %d changed width", index)
}
}
editor.Cursor = strings.Index(markdown, "code")
editor.handleKey(runeKey("s"), true)
editor.handleKey(runeKey("C"), true)
if editor.Text != strings.Replace(markdown, "code", "Code", 1) {
t.Fatalf("highlighted edit changed wrong rune: %q", editor.Text)
}
}
func TestMarkdownHighlightTokenKinds(t *testing.T) {
const markdown = "# Heading\nText **strong** and *emphasis* with `code` and [link](target)\n<!-- comment -->\n"
styles := editorMarkdownStyles(markdown)
assertStyleAt := func(fragment string, want editorMarkdownStyle) {
t.Helper()
index := len([]rune(markdown[:strings.Index(markdown, fragment)]))
if styles[index] != want {
t.Fatalf("style for %q = %d, want %d", fragment, styles[index], want)
}
}
assertStyleAt("# Heading", editorMarkdownHeading)
assertStyleAt("**strong**", editorMarkdownStrong)
assertStyleAt("*emphasis*", editorMarkdownEmphasis)
assertStyleAt("`code`", editorMarkdownCode)
assertStyleAt("link", editorMarkdownLink)
assertStyleAt("target", editorMarkdownDestination)
assertStyleAt("<!-- comment -->", editorMarkdownComment)
}
func joinEditorLines(lines []editorRenderedLine) string {
var values []string
for _, line := range lines {
values = append(values, line.text)
}
return strings.Join(values, "")
}
func runeKey(value string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/charmbracelet/lipgloss"
)
var currentThemeName = "dark"
func applyTheme(name string) error {
colorEnabled = true
switch name {
@@ -16,6 +18,9 @@ func applyTheme(name string) error {
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#D7DAE8")).
Background(lipgloss.Color("#2C3045"))
paneInactiveColor = lipgloss.Color("#50566F")
paneActiveColor = lipgloss.Color("#F0B72F")
authorPalette = []lipgloss.Color{
@@ -37,6 +42,9 @@ func applyTheme(name string) error {
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#1A7F37"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9A6700"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CF222E"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#24292F")).
Background(lipgloss.Color("#DDE8FF"))
paneInactiveColor = lipgloss.Color("#8C959F")
paneActiveColor = lipgloss.Color("#0969DA")
authorPalette = []lipgloss.Color{
@@ -58,6 +66,9 @@ func applyTheme(name string) error {
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"
@@ -71,6 +82,7 @@ func applyTheme(name string) error {
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"
@@ -80,6 +92,7 @@ func applyTheme(name string) error {
default:
return fmt.Errorf("unknown theme %q", name)
}
currentThemeName = name
commentMarkdownRenderers.Clear()
return nil
}

166
tui.go
View File

@@ -42,6 +42,9 @@ const (
writeReplyBusy
writeResolveConfirm
writeResolveBusy
writePREdit
writePREditConfirm
writePREditBusy
)
type threadResolvedMsg struct {
@@ -56,6 +59,11 @@ type threadRepliedMsg struct {
err error
}
type pullRequestUpdatedMsg struct {
metadata PullRequestMetadata
err error
}
type prsLoadedMsg struct {
prs []PullRequest
err error
@@ -70,6 +78,13 @@ type detailsLoadedMsg struct {
cached bool
}
type branchesLoadedMsg struct {
owner string
repo string
branches []RepositoryBranch
err error
}
type App struct {
service GitHubService
owner, repo string
@@ -77,29 +92,37 @@ type App struct {
limit int
poll time.Duration
screen screen
prs []PullRequest
prIndex int
details PRDetails
threadIndex int
folded map[string]bool
focus pane
listHidden bool
scroll int
width, height int
loading bool
err error
lastRefresh time.Time
pendingZ bool
searching bool
searchQuery string
searchOrigin int
helpVisible bool
helpScroll int
writeMode writeMode
writeThreadID string
replyDraft string
resolveTarget bool
screen screen
prs []PullRequest
prIndex int
details PRDetails
threadIndex int
folded map[string]bool
focus pane
listHidden bool
scroll int
width, height int
loading bool
err error
lastRefresh time.Time
pendingZ bool
searching bool
searchQuery string
searchOrigin int
helpVisible bool
helpScroll int
writeMode writeMode
writeThreadID string
replyDraft string
resolveTarget bool
prEditField int
prEditEditors [3]textEditor
prEditOriginal PullRequestMetadata
prEditBranches []RepositoryBranch
prEditBranchesLoading bool
prEditBranchesError string
prEditBranchIndex int
cursorOutput *terminalCursorOutput
foldResolved bool
threadListWidthPercent int
@@ -111,6 +134,7 @@ type App struct {
dashboardMode string
dashboardReturn screen
compactReviews bool
editorMode string
readState *readStateStore
knownThreads map[string]bool
knownComments map[string]bool
@@ -128,6 +152,7 @@ type AppSettings struct {
ThreadWithinStatus string
DashboardMode string
CompactReviews bool
EditorMode string
ReadState *readStateStore
}
@@ -140,6 +165,7 @@ func defaultAppSettings() AppSettings {
ThreadWithinStatus: "file",
DashboardMode: "hotkey",
CompactReviews: true,
EditorMode: "vim",
}
}
@@ -168,6 +194,7 @@ func NewAppWithSettings(
threadWithinStatus: settings.ThreadWithinStatus,
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
compactReviews: settings.CompactReviews,
editorMode: settings.EditorMode,
readState: state,
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
@@ -351,6 +378,8 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "n", "esc":
m.writeMode, m.writeThreadID = writeNone, ""
}
case writePREdit, writePREditConfirm:
return m.updatePREditInput(key)
}
return m, nil
}
@@ -485,6 +514,21 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.lastRefresh = time.Now()
}
case branchesLoadedMsg:
if m.writeMode != writePREdit ||
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
return m, nil
}
m.prEditBranchesLoading = false
if msg.err != nil {
m.prEditBranchesError = msg.err.Error()
m.ensurePREditCursorVisible()
return m, nil
}
m.prEditBranches = msg.branches
m.prEditBranchesError = ""
m.prEditBranchIndex = 0
m.ensurePREditCursorVisible()
case threadResolvedMsg:
m.writeMode = writeNone
if msg.err != nil {
@@ -532,6 +576,34 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.replyDraft, m.writeThreadID = "", ""
m.err = nil
m.lastRefresh = time.Now()
case pullRequestUpdatedMsg:
if msg.err != nil {
m.writeMode = writePREdit
m.err = fmt.Errorf("update pull request: %w", msg.err)
m.scroll = 0
return m, nil
}
m.writeMode = writeNone
m.details.Title = msg.metadata.Title
m.details.Body = msg.metadata.Body
m.details.BaseRef = msg.metadata.BaseRef
m.details.Mergeable = msg.metadata.Mergeable
m.details.MergeState = msg.metadata.MergeState
m.details.UpdatedAt = msg.metadata.UpdatedAt
m.details.ConflictFiles = nil
m.details.ConflictFileError = ""
for index := range m.prs {
if m.prs[index].ID == m.details.ID {
m.prs[index].Title = msg.metadata.Title
m.prs[index].UpdatedAt = msg.metadata.UpdatedAt
break
}
}
m.clearPREdit()
m.err = nil
m.lastRefresh = time.Now()
m.loading = true
return m, m.loadDetails(m.details.PullRequest, false)
}
key, ok := msg.(tea.KeyMsg)
@@ -624,6 +696,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == threadScreen {
m.startResolveToggle()
}
case "e":
if m.screen == dashboardScreen {
return m, m.startPREdit()
}
case "F":
if m.screen == threadScreen {
m.searchQuery = ""
@@ -1145,10 +1221,16 @@ func (m App) detailMaxScroll() int {
}
func (m App) View() string {
if m.cursorOutput != nil {
m.cursorOutput.SetCursor(false, 0, 0)
}
if m.width == 0 {
return "Loading…"
}
if m.writeMode != writeNone && m.writeMode != writeReply {
if m.writeMode == writePREdit {
return m.viewDashboard()
}
return m.viewWritePopup()
}
if m.helpVisible {
@@ -1208,6 +1290,10 @@ func (m App) viewWritePopup() string {
action = "Unresolving"
}
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
case writePREditConfirm:
lines = m.prEditConfirmationLines(width - 2)
case writePREditBusy:
lines = []string{titleStyle.Render("Updating pull request…")}
}
maxLines := max(3, m.height-4)
if len(lines) > maxLines {
@@ -1263,6 +1349,7 @@ func (m App) helpBindings() []helpBinding {
{"k / ↑", "Scroll description up"},
{"g / G", "Top / bottom"},
{"ctrl-d / ctrl-u", "Page down / up"},
{"e", "Edit title, target branch, and description"},
{"enter / l", "Open review threads"},
{"b / esc", backAction},
{"r", "Refresh now"},
@@ -1359,12 +1446,15 @@ func (m App) helpRows(contentWidth int) []string {
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
editorLineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#D7DAE8")).
Background(lipgloss.Color("#2C3045"))
paneInactiveColor = lipgloss.Color("#50566F")
paneActiveColor = lipgloss.Color("#F0B72F")
@@ -1462,10 +1552,22 @@ func (m App) viewDashboard() string {
maxScroll := max(0, len(lines)-viewportHeight)
scroll := min(m.scroll, maxScroll)
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
return m.frame(visible, "? keys • j/k scroll • enter threads • b back • q quit")
footer := "? keys • j/k scroll • enter threads • b back • q quit"
if m.writeMode == writePREdit {
footer = "tab fields • ctrl-d/u page • v/V select • y copy • p paste • ctrl-s review • esc normal/cancel"
m.positionPREditHardwareCursor(scroll, viewportHeight)
}
view := m.frame(visible, footer)
if m.cursorOutput != nil {
view += m.cursorOutput.FrameMarker()
}
return view
}
func (m App) dashboardLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
}
pr := m.details
width := max(10, m.width-2)
draft := ""
@@ -1799,7 +1901,7 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
reason := "offline cached snapshot"
return []writeCapability{
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
{name: "react", reason: reason}, {name: "update branch", reason: reason},
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
{name: "auto-merge", reason: reason},
}
}
@@ -1820,7 +1922,7 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
capability("reply", canReply, threadReason, true),
capability(resolveName, canResolve, threadReason, true),
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", false),
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR", false),
}
}

View File

@@ -2,6 +2,9 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"testing"
@@ -21,6 +24,36 @@ type recordingService struct {
writeResolved bool
}
type recordingPRService struct {
recordingService
updateID string
update PullRequestMetadata
updateErr error
branches []RepositoryBranch
branchErr error
}
func (s *recordingPRService) UpdatePullRequest(
_ context.Context,
id string,
update PullRequestMetadata,
) (PullRequestMetadata, error) {
s.updateID, s.update = id, update
if s.updateErr != nil {
return PullRequestMetadata{}, s.updateErr
}
update.Mergeable = "UNKNOWN"
update.MergeState = "UNKNOWN"
update.UpdatedAt = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
return update, nil
}
func (s *recordingPRService) ListBranches(
_ context.Context, _, _ string,
) ([]RepositoryBranch, error) {
return append([]RepositoryBranch(nil), s.branches...), s.branchErr
}
func (s *recordingService) SetThreadResolved(
_ context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
@@ -513,6 +546,334 @@ func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
if !live[0].enabled || live[1].enabled || live[2].enabled {
t.Fatalf("live capabilities = %#v", live)
}
updatable := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanUpdatePR: true}}, thread)
if !updatable[3].enabled || updatable[3].name != "update pull request" {
t.Fatalf("pull request update capability = %#v", updatable[3])
}
}
func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
service := &recordingPRService{}
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: "Old title",
},
Body: "- [ ] first\n- [ ] second", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
send := func(key tea.KeyMsg) tea.Cmd {
updated, command := m.Update(key)
m = updated.(App)
return command
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
if m.writeMode != writePREdit || m.prEditField != prEditBodyField {
t.Fatalf("edit key produced mode=%d field=%d", m.writeMode, m.prEditField)
}
editor := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
if !strings.Contains(editor, "EDITING") || !strings.Contains(editor, "- [ ] first") {
t.Fatalf("dashboard editor does not show the raw description:\n%s", editor)
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")})
send(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";")})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")})
send(tea.KeyMsg{Type: tea.KeyDelete})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
send(tea.KeyMsg{Type: tea.KeyEsc})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("main") {
send(tea.KeyMsg{Type: tea.KeyDelete})
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("Old title") {
send(tea.KeyMsg{Type: tea.KeyDelete})
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New title")})
send(tea.KeyMsg{Type: tea.KeyCtrlS})
if m.writeMode != writePREditConfirm {
t.Fatalf("ctrl-s produced mode=%d, error=%v", m.writeMode, m.err)
}
command := send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
if command == nil || m.writeMode != writePREditBusy {
t.Fatalf("confirmation produced mode=%d command=%v", m.writeMode, command)
}
updated, refresh := m.Update(command())
m = updated.(App)
if refresh == nil || service.updateID != "pr" {
t.Fatalf("update did not submit and refresh: id=%q refresh=%v", service.updateID, refresh)
}
if service.update.Title != "New title" || service.update.BaseRef != "release" ||
service.update.Body != "- [x] first\n- [ ] second" {
t.Fatalf("submitted metadata = %#v", service.update)
}
if m.writeMode != writeNone || m.details.Title != "New title" ||
m.details.BaseRef != "release" || m.details.Body != service.update.Body {
t.Fatalf("local metadata was not updated: mode=%d details=%#v", m.writeMode, m.details)
}
}
func TestDashboardEditorPreservesDraftAfterMutationFailure(t *testing.T) {
service := &recordingPRService{updateErr: errors.New("base branch does not exist")}
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",
},
Body: "description", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Text = "changed description"
m.prEditEditors[prEditBodyField].Cursor = len([]rune("changed description"))
m.writeMode = writePREditBusy
message := m.submitPREdit()()
updated, _ := m.Update(message)
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Text != "changed description" ||
m.err == nil || !strings.Contains(m.err.Error(), "base branch does not exist") {
t.Fatalf("failed mutation lost editor state: mode=%d body=%q err=%v",
m.writeMode, m.prEditEditors[prEditBodyField].Text, m.err)
}
}
func TestDashboardEditorRejectsStaleMetadata(t *testing.T) {
service := &recordingPRService{}
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", Title: "Title"},
Body: "original", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Text = "my edit"
m.details.Body = "remote edit"
updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlS})
m = updated.(App)
if command != nil || m.writeMode != writePREdit || m.err == nil ||
!strings.Contains(m.err.Error(), "changed while editing") {
t.Fatalf("stale metadata was not blocked: mode=%d command=%v err=%v", m.writeMode, command, m.err)
}
}
func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(&recordingPRService{}, "o", "r", false, 50, time.Second, settings)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
editor := m.prEditEditors[prEditBodyField]
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
t.Fatalf("standard description editor = %#v", editor)
}
}
func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) {
m := NewApp(&recordingPRService{}, "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", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
updated, _ := m.updatePREditInput(runeKey("i"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
t.Fatalf("insert escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
}
updated, _ = m.updatePREditInput(runeKey("v"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
t.Fatalf("visual escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
}
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writeNone {
t.Fatalf("normal escape did not close editor: write=%d", m.writeMode)
}
}
func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "abcdefghij", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Cursor = 4
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
m.positionPREditHardwareCursor(0, m.dashboardViewportHeight())
m.cursorOutput.mu.Lock()
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row
m.cursorOutput.mu.Unlock()
if !visible || column != 7 || row <= 0 {
t.Fatalf("hardware cursor visible=%v column=%d row=%d", visible, column, row)
}
}
func TestDashboardReturningToTitleRestoresTopAndFieldLabel(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: strings.Repeat("description line\n", 30), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.scroll = 30
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if m.prEditField != prEditTitleField || m.scroll != 0 {
t.Fatalf("title navigation field=%d scroll=%d", m.prEditField, m.scroll)
}
view := ansi.Strip(m.viewDashboard())
if !strings.Contains(view, "Edit pull request") || !strings.Contains(view, "title") {
t.Fatalf("title context is not visible:\n%s", view)
}
}
func TestDashboardEditorHalfPageMotionsMoveCursorAndViewport(t *testing.T) {
var body strings.Builder
for index := range 30 {
fmt.Fprintf(&body, "line %02d\n", index)
}
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: body.String(), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
startCursor := m.prEditEditors[prEditBodyField].Cursor
startScroll := m.scroll
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(App)
if m.prEditEditors[prEditBodyField].Cursor <= startCursor || m.scroll <= startScroll {
t.Fatalf("ctrl-d cursor=%d scroll=%d", m.prEditEditors[prEditBodyField].Cursor, m.scroll)
}
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlU})
m = updated.(App)
if m.prEditEditors[prEditBodyField].Cursor != startCursor || m.scroll != startScroll {
t.Fatalf(
"ctrl-u cursor=%d want=%d scroll=%d want=%d",
m.prEditEditors[prEditBodyField].Cursor, startCursor, m.scroll, startScroll,
)
}
}
func TestDashboardEditorHalfPageMotionExtendsVisualSelection(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 30, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: strings.Repeat("abcdefghij", 20), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
updated, _ := m.updatePREditInput(runeKey("v"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(App)
editor := m.prEditEditors[prEditBodyField]
start, end, selected := editor.selectionBounds(m.prEditEditorWidth())
if editor.Mode != textEditorVisual || !selected || end-start <= 1 {
t.Fatalf("visual ctrl-d mode=%s selection=%d:%d selected=%v", editor.Mode, start, end, selected)
}
}
func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "abcdef", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
first := m.viewDashboard()
m.prEditEditors[prEditBodyField].Cursor++
second := m.viewDashboard()
if first == second {
t.Fatal("hardware-cursor-only movement produced an identical frame")
}
if ansi.Strip(first) != ansi.Strip(second) {
t.Fatal("hardware cursor marker changed visible frame content")
}
}
func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
const remoteBody = "first\nsecond\r\nthird\r"
m := NewApp(&recordingPRService{}, "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", Title: "Title"},
Body: remoteBody, BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
if got := m.prEditEditors[prEditBodyField].Text; got != "first\nsecond\nthird\n" {
t.Fatalf("editor body = %q", got)
}
if got := m.prEditMetadata().Body; got != remoteBody {
t.Fatalf("unchanged payload body = %q, want original %q", got, remoteBody)
}
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "unchanged") {
t.Fatalf("line-ending normalization counted as an edit: %v", err)
}
m.prEditEditors[prEditBodyField].Text += "changed"
if got := m.prEditMetadata().Body; strings.ContainsRune(got, '\r') {
t.Fatalf("edited payload retained carriage returns: %q", got)
}
}
func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {

View File

@@ -55,6 +55,21 @@ type PRDetails struct {
CachedAt time.Time
}
type PullRequestMetadata struct {
Title string
Body string
BaseRef string
Mergeable string
MergeState string
UpdatedAt time.Time
}
type RepositoryBranch struct {
Name string
UpdatedAt time.Time
IsDefault bool
}
type Check struct {
ID string
Name string