diff --git a/README.md b/README.md index a62ae22..1ca6754 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md index e0b0eb6..454ce51 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/branch_completion.go b/branch_completion.go new file mode 100644 index 0000000..6da21e5 --- /dev/null +++ b/branch_completion.go @@ -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 +} diff --git a/branch_completion_test.go b/branch_completion_test.go new file mode 100644 index 0000000..736b855 --- /dev/null +++ b/branch_completion_test.go @@ -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) + } +} diff --git a/cache.go b/cache.go index 1e0c266..e3a760f 100644 --- a/cache.go +++ b/cache.go @@ -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) } diff --git a/config.go b/config.go index 6ba5dd3..a715faa 100644 --- a/config.go +++ b/config.go @@ -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 } diff --git a/config_test.go b/config_test.go index 6232aa5..aeec10d 100644 --- a/config_test.go +++ b/config_test.go @@ -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") + } +} diff --git a/github.go b/github.go index 11660e0..fdfb46a 100644 --- a/github.go +++ b/github.go @@ -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, diff --git a/github_test.go b/github_test.go index 55b063e..516d6fe 100644 --- a/github_test.go +++ b/github_test.go @@ -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 diff --git a/main.go b/main.go index e544bbe..1af8543 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/markdown_editor_highlight.go b/markdown_editor_highlight.go new file mode 100644 index 0000000..7b1c355 --- /dev/null +++ b/markdown_editor_highlight.go @@ -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("") + 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 +} diff --git a/pr_editor.go b/pr_editor.go new file mode 100644 index 0000000..71167e6 --- /dev/null +++ b/pr_editor.go @@ -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 +} diff --git a/system_clipboard.go b/system_clipboard.go new file mode 100644 index 0000000..59a6107 --- /dev/null +++ b/system_clipboard.go @@ -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") + } +} diff --git a/terminal_cursor.go b/terminal_cursor.go new file mode 100644 index 0000000..19eda45 --- /dev/null +++ b/terminal_cursor.go @@ -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() +} diff --git a/terminal_cursor_test.go b/terminal_cursor_test.go new file mode 100644 index 0000000..3acd408 --- /dev/null +++ b/terminal_cursor_test.go @@ -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) + } +} diff --git a/text_editor.go b/text_editor.go new file mode 100644 index 0000000..27bb27b --- /dev/null +++ b/text_editor.go @@ -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() +} diff --git a/text_editor_test.go b/text_editor_test.go new file mode 100644 index 0000000..6833c4e --- /dev/null +++ b/text_editor_test.go @@ -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\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("", 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)} +} diff --git a/theme.go b/theme.go index f8522a8..eabe7cc 100644 --- a/theme.go +++ b/theme.go @@ -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 } diff --git a/tui.go b/tui.go index 66b1c3d..000555c 100644 --- a/tui.go +++ b/tui.go @@ -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), } } diff --git a/tui_test.go b/tui_test.go index b451877..6079f8d 100644 --- a/tui_test.go +++ b/tui_test.go @@ -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) { diff --git a/types.go b/types.go index c4c0571..35ac84a 100644 --- a/types.go +++ b/types.go @@ -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