Make dashboard updateable
This commit is contained in:
53
README.md
53
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.
|
||||
|
||||
3
TODO.md
3
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.
|
||||
|
||||
205
branch_completion.go
Normal file
205
branch_completion.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type branchSuggestion struct {
|
||||
branch RepositoryBranch
|
||||
score int
|
||||
}
|
||||
|
||||
func rankBranchSuggestions(
|
||||
branches []RepositoryBranch,
|
||||
query, current string,
|
||||
now time.Time,
|
||||
) []branchSuggestion {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
current = strings.ToLower(current)
|
||||
suggestions := make([]branchSuggestion, 0, len(branches))
|
||||
for _, branch := range branches {
|
||||
name := strings.ToLower(branch.Name)
|
||||
matchScore := 0
|
||||
if query != "" {
|
||||
var matches bool
|
||||
matchScore, matches = fuzzyTermScore([]rune(name), []rune(query))
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case name == query:
|
||||
matchScore += 50000
|
||||
case strings.HasPrefix(name, query):
|
||||
matchScore += 30000
|
||||
case branchSegmentHasPrefix(name, query):
|
||||
matchScore += 20000
|
||||
}
|
||||
}
|
||||
|
||||
score := matchScore * 10
|
||||
if branch.IsDefault {
|
||||
score += 9000
|
||||
}
|
||||
if name == current {
|
||||
score += 7000
|
||||
}
|
||||
switch name {
|
||||
case "main", "master":
|
||||
score += 3500
|
||||
case "develop", "development", "dev":
|
||||
score += 2500
|
||||
}
|
||||
if strings.HasPrefix(name, "release/") || strings.HasPrefix(name, "release-") {
|
||||
score += 1800
|
||||
}
|
||||
score += branchFreshnessScore(branch.UpdatedAt, now)
|
||||
suggestions = append(suggestions, branchSuggestion{branch: branch, score: score})
|
||||
}
|
||||
sort.SliceStable(suggestions, func(i, j int) bool {
|
||||
if suggestions[i].score != suggestions[j].score {
|
||||
return suggestions[i].score > suggestions[j].score
|
||||
}
|
||||
if !suggestions[i].branch.UpdatedAt.Equal(suggestions[j].branch.UpdatedAt) {
|
||||
return suggestions[i].branch.UpdatedAt.After(suggestions[j].branch.UpdatedAt)
|
||||
}
|
||||
return strings.ToLower(suggestions[i].branch.Name) <
|
||||
strings.ToLower(suggestions[j].branch.Name)
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func branchSegmentHasPrefix(name, query string) bool {
|
||||
for _, segment := range strings.FieldsFunc(name, func(value rune) bool {
|
||||
return strings.ContainsRune("/._-", value)
|
||||
}) {
|
||||
if strings.HasPrefix(segment, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func branchFreshnessScore(updatedAt, now time.Time) int {
|
||||
if updatedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
age := now.Sub(updatedAt)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
days := int(age / (24 * time.Hour))
|
||||
return max(0, 3000-min(days, 3000))
|
||||
}
|
||||
|
||||
func branchAgeLabel(updatedAt, now time.Time) string {
|
||||
if updatedAt.IsZero() {
|
||||
return "age unknown"
|
||||
}
|
||||
age := now.Sub(updatedAt)
|
||||
if age < time.Hour {
|
||||
return "updated recently"
|
||||
}
|
||||
if age < 24*time.Hour {
|
||||
return fmt.Sprintf("updated %dh ago", int(age/time.Hour))
|
||||
}
|
||||
days := int(age / (24 * time.Hour))
|
||||
if days < 30 {
|
||||
return fmt.Sprintf("updated %dd ago", days)
|
||||
}
|
||||
months := days / 30
|
||||
if months < 24 {
|
||||
return fmt.Sprintf("updated %dmo ago", months)
|
||||
}
|
||||
return fmt.Sprintf("updated %dy ago", days/365)
|
||||
}
|
||||
|
||||
func (m App) branchSuggestions() []branchSuggestion {
|
||||
suggestions := rankBranchSuggestions(
|
||||
m.prEditBranches,
|
||||
m.prEditEditors[prEditBaseField].Text,
|
||||
m.prEditOriginal.BaseRef,
|
||||
time.Now(),
|
||||
)
|
||||
const maximumVisibleSuggestions = 6
|
||||
if len(suggestions) > maximumVisibleSuggestions {
|
||||
suggestions = suggestions[:maximumVisibleSuggestions]
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func (m *App) moveBranchSuggestion(delta int) {
|
||||
suggestions := m.branchSuggestions()
|
||||
if len(suggestions) == 0 {
|
||||
m.prEditBranchIndex = 0
|
||||
return
|
||||
}
|
||||
m.prEditBranchIndex = (m.prEditBranchIndex + delta + len(suggestions)) % len(suggestions)
|
||||
}
|
||||
|
||||
func (m *App) completeBranchSuggestion() bool {
|
||||
suggestions := m.branchSuggestions()
|
||||
if len(suggestions) == 0 {
|
||||
return false
|
||||
}
|
||||
index := clamp(m.prEditBranchIndex, 0, len(suggestions)-1)
|
||||
name := suggestions[index].branch.Name
|
||||
editor := &m.prEditEditors[prEditBaseField]
|
||||
if editor.Text == name {
|
||||
return false
|
||||
}
|
||||
editor.Text = name
|
||||
editor.Cursor = len([]rune(name))
|
||||
m.prEditBranchIndex = 0
|
||||
m.err = nil
|
||||
return true
|
||||
}
|
||||
|
||||
func (m App) branchCompletionLines(width int) []string {
|
||||
width = max(1, width)
|
||||
if m.prEditBranchesLoading {
|
||||
return []string{dimStyle.Render(" loading repository branches…")}
|
||||
}
|
||||
if m.prEditBranchesError != "" {
|
||||
message := " branch recommendations unavailable: " + m.prEditBranchesError
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(message, width, ""), width, false)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, warnStyle.Render(line))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
suggestions := m.branchSuggestions()
|
||||
if len(suggestions) == 0 {
|
||||
return []string{dimStyle.Render(" no matching repository branches")}
|
||||
}
|
||||
lines := []string{dimStyle.Render(" ctrl-p/n choose • tab/enter complete • tab again advances")}
|
||||
now := time.Now()
|
||||
for index, suggestion := range suggestions {
|
||||
prefix := " "
|
||||
if index == clamp(m.prEditBranchIndex, 0, len(suggestions)-1) {
|
||||
prefix = " ▶ "
|
||||
}
|
||||
suffix := branchAgeLabel(suggestion.branch.UpdatedAt, now)
|
||||
if suggestion.branch.IsDefault {
|
||||
suffix = "default • " + suffix
|
||||
}
|
||||
if suggestion.branch.Name == m.prEditOriginal.BaseRef {
|
||||
suffix = "current • " + suffix
|
||||
}
|
||||
available := max(1, width-len([]rune(prefix))-len([]rune(suffix))-2)
|
||||
name := ansi.Truncate(suggestion.branch.Name, available, "…")
|
||||
line := prefix + name + strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) + dimStyle.Render(suffix)
|
||||
if strings.HasPrefix(prefix, " ▶") {
|
||||
line = titleStyle.Render(prefix+name) +
|
||||
strings.Repeat(" ", max(1, available-ansi.StringWidth(name)+1)) +
|
||||
dimStyle.Render(suffix)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
104
branch_completion_test.go
Normal file
104
branch_completion_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestBranchSuggestionsPreferLikelyAndFreshBranches(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
branches := []RepositoryBranch{
|
||||
{Name: "old-feature", UpdatedAt: now.AddDate(-2, 0, 0)},
|
||||
{Name: "recent-feature", UpdatedAt: now.Add(-time.Hour)},
|
||||
{Name: "main", UpdatedAt: now.AddDate(0, -6, 0), IsDefault: true},
|
||||
}
|
||||
suggestions := rankBranchSuggestions(branches, "", "main", now)
|
||||
if len(suggestions) != 3 ||
|
||||
suggestions[0].branch.Name != "main" ||
|
||||
suggestions[1].branch.Name != "recent-feature" {
|
||||
t.Fatalf("unexpected ranking: %#v", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchSuggestionsReactToFuzzyInput(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
branches := []RepositoryBranch{
|
||||
{Name: "feature/relation", UpdatedAt: now},
|
||||
{Name: "release/2.0", UpdatedAt: now.AddDate(-1, 0, 0)},
|
||||
{Name: "main", UpdatedAt: now, IsDefault: true},
|
||||
}
|
||||
suggestions := rankBranchSuggestions(branches, "rel", "main", now)
|
||||
if len(suggestions) != 2 || suggestions[0].branch.Name != "release/2.0" {
|
||||
t.Fatalf("prefix match was not preferred: %#v", suggestions)
|
||||
}
|
||||
suggestions = rankBranchSuggestions(branches, "frel", "main", now)
|
||||
if len(suggestions) != 1 || suggestions[0].branch.Name != "feature/relation" {
|
||||
t.Fatalf("fuzzy match failed: %#v", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
|
||||
service := &recordingPRService{branches: []RepositoryBranch{
|
||||
{Name: "main", IsDefault: true},
|
||||
{Name: "release/2.0"},
|
||||
{Name: "release/1.0"},
|
||||
}}
|
||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{
|
||||
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
|
||||
Number: 1, Title: "Title",
|
||||
},
|
||||
BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
command := m.startPREdit()
|
||||
if command == nil {
|
||||
t.Fatal("opening the editor did not request branches")
|
||||
}
|
||||
updated, _ := m.Update(command())
|
||||
m = updated.(App)
|
||||
m.prEditField = prEditBaseField
|
||||
m.prEditEditors[prEditBaseField] = newTextEditor("release", false)
|
||||
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlN})
|
||||
m = updated.(App)
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = updated.(App)
|
||||
if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" &&
|
||||
got != "release/1.0" {
|
||||
t.Fatalf("tab did not complete selected branch: %q", got)
|
||||
}
|
||||
if m.prEditField != prEditBaseField {
|
||||
t.Fatalf("completion moved away from target branch: field=%d", m.prEditField)
|
||||
}
|
||||
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = updated.(App)
|
||||
if m.prEditField != prEditBodyField {
|
||||
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) {
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.width = 80
|
||||
m.prEditField = prEditBaseField
|
||||
m.prEditOriginal = PullRequestMetadata{BaseRef: "main"}
|
||||
m.prEditEditors[prEditTitleField] = newTextEditor("Title", false)
|
||||
m.prEditEditors[prEditBaseField] = newTextEditor("rel", false)
|
||||
m.prEditEditors[prEditBodyField] = newTextEditor("", true)
|
||||
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
|
||||
|
||||
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
|
||||
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab/enter complete") {
|
||||
t.Fatalf("branch suggestions missing:\n%s", view)
|
||||
}
|
||||
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {
|
||||
t.Fatalf("unknown branch validation error = %v", err)
|
||||
}
|
||||
}
|
||||
22
cache.go
22
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)
|
||||
}
|
||||
|
||||
11
config.go
11
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",
|
||||
@@ -81,6 +86,7 @@ func defaultConfig() Config {
|
||||
WithinStatus: "file",
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) {
|
||||
got.RefreshInterval.Duration != want.RefreshInterval.Duration ||
|
||||
got.Paths.Scroll != want.Paths.Scroll ||
|
||||
got.Display.FoldResolved != want.Display.FoldResolved ||
|
||||
got.Display.CompactReviews != want.Display.CompactReviews {
|
||||
got.Display.CompactReviews != want.Display.CompactReviews ||
|
||||
got.Editing.Mode != "vim" {
|
||||
t.Fatalf("defaults = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +53,9 @@ within_status = "timestamp"
|
||||
enabled = false
|
||||
max_age = "48h"
|
||||
directory = "/tmp/gh-threads-cache"
|
||||
|
||||
[editing]
|
||||
mode = "standard"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -71,7 +75,8 @@ directory = "/tmp/gh-threads-cache"
|
||||
!got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond ||
|
||||
strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" ||
|
||||
got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled ||
|
||||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" {
|
||||
got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" ||
|
||||
got.Editing.Mode != "standard" {
|
||||
t.Fatalf("config = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -164,3 +169,11 @@ func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) {
|
||||
t.Fatal("unknown dashboard mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
config.Editing.Mode = "emacs"
|
||||
if err := validateConfig(config); err == nil {
|
||||
t.Fatal("unknown editor mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
129
github.go
129
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,
|
||||
|
||||
@@ -8,8 +8,62 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListBranchesPaginatesAndMarksTheDefaultBranch(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
var request graphQLRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(request.Query, "query RepositoryBranches") {
|
||||
t.Fatalf("unexpected query: %s", request.Query)
|
||||
}
|
||||
if requests == 1 {
|
||||
if request.Variables["after"] != nil {
|
||||
t.Fatalf("first cursor = %#v", request.Variables["after"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||
"defaultBranchRef":{"name":"main"},
|
||||
"refs":{"pageInfo":{"hasNextPage":true,"endCursor":"next"},"nodes":[
|
||||
{"name":"feature/old","target":{"committedDate":"2024-01-01T00:00:00Z"}},
|
||||
{"name":"main","target":{"committedDate":"2025-01-01T00:00:00Z"}}
|
||||
]}
|
||||
}}}`))
|
||||
return
|
||||
}
|
||||
if request.Variables["after"] != "next" {
|
||||
t.Fatalf("second cursor = %#v", request.Variables["after"])
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"repository":{
|
||||
"defaultBranchRef":{"name":"main"},
|
||||
"refs":{"pageInfo":{"hasNextPage":false},"nodes":[
|
||||
{"name":"release/2.0","target":{"committedDate":"2026-07-28T00:00:00Z"}}
|
||||
]}
|
||||
}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewGitHubClient(server.URL, "secret")
|
||||
branches, err := client.ListBranches(context.Background(), "o", "r")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != 2 || len(branches) != 3 {
|
||||
t.Fatalf("requests=%d branches=%#v", requests, branches)
|
||||
}
|
||||
if branches[0].Name != "main" || !branches[0].IsDefault {
|
||||
t.Fatalf("default branch was not first and marked: %#v", branches)
|
||||
}
|
||||
wantUpdated := time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC)
|
||||
if branches[1].Name != "release/2.0" || !branches[1].UpdatedAt.Equal(wantUpdated) {
|
||||
t.Fatalf("fresh branch was not decoded and sorted: %#v", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPullRequestsSearchesAssignedPRsInRepository(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||
@@ -176,6 +230,40 @@ func TestThreadWriteMutationsUseThreadIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request graphQLRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(request.Query, "mutation UpdatePullRequest") {
|
||||
t.Fatalf("unexpected mutation:\n%s", request.Query)
|
||||
}
|
||||
input := request.Variables["input"].(map[string]any)
|
||||
if input["pullRequestId"] != "pr-id" || input["title"] != "New title" ||
|
||||
input["body"] != "- [x] done" || input["baseRefName"] != "release" {
|
||||
t.Fatalf("update input = %#v", input)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"updatePullRequest":{"pullRequest":{
|
||||
"id":"pr-id","title":"New title","body":"- [x] done","baseRefName":"release",
|
||||
"updatedAt":"2026-07-28T12:00:00Z","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN"
|
||||
}}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewGitHubClient(server.URL, "secret")
|
||||
got, err := client.UpdatePullRequest(context.Background(), "pr-id", PullRequestMetadata{
|
||||
Title: "New title", Body: "- [x] done", BaseRef: "release",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "New title" || got.Body != "- [x] done" || got.BaseRef != "release" ||
|
||||
got.Mergeable != "UNKNOWN" || got.UpdatedAt.IsZero() {
|
||||
t.Fatalf("updated pull request = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request graphQLRequest
|
||||
|
||||
15
main.go
15
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)
|
||||
|
||||
182
markdown_editor_highlight.go
Normal file
182
markdown_editor_highlight.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
)
|
||||
|
||||
type editorMarkdownStyle uint8
|
||||
|
||||
const (
|
||||
editorMarkdownPlain editorMarkdownStyle = iota
|
||||
editorMarkdownHeading
|
||||
editorMarkdownStrong
|
||||
editorMarkdownEmphasis
|
||||
editorMarkdownCode
|
||||
editorMarkdownLink
|
||||
editorMarkdownDestination
|
||||
editorMarkdownQuote
|
||||
editorMarkdownComment
|
||||
)
|
||||
|
||||
func editorMarkdownStyles(value string) []editorMarkdownStyle {
|
||||
runes := []rune(value)
|
||||
styles := make([]editorMarkdownStyle, len(runes))
|
||||
lexer := lexers.Get("markdown")
|
||||
if lexer == nil {
|
||||
highlightEditorHTMLComments(runes, styles)
|
||||
return styles
|
||||
}
|
||||
tokens, err := chroma.Tokenise(lexer, nil, value)
|
||||
if err != nil {
|
||||
highlightEditorHTMLComments(runes, styles)
|
||||
return styles
|
||||
}
|
||||
offset := 0
|
||||
for _, token := range tokens {
|
||||
style := editorMarkdownStyleForToken(token.Type)
|
||||
for range []rune(token.Value) {
|
||||
if offset >= len(styles) {
|
||||
return styles
|
||||
}
|
||||
styles[offset] = style
|
||||
offset++
|
||||
}
|
||||
}
|
||||
highlightEditorHTMLComments(runes, styles)
|
||||
return styles
|
||||
}
|
||||
|
||||
func highlightEditorHTMLComments(runes []rune, styles []editorMarkdownStyle) {
|
||||
startMarker, endMarker := []rune("<!--"), []rune("-->")
|
||||
for offset := 0; offset < len(runes); {
|
||||
start := findEditorRunes(runes, startMarker, offset)
|
||||
if start < 0 {
|
||||
return
|
||||
}
|
||||
end := findEditorRunes(runes, endMarker, start+len(startMarker))
|
||||
if end < 0 {
|
||||
end = len(runes)
|
||||
} else {
|
||||
end += len(endMarker)
|
||||
}
|
||||
for index := start; index < end; index++ {
|
||||
styles[index] = editorMarkdownComment
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
}
|
||||
|
||||
func findEditorRunes(value, target []rune, offset int) int {
|
||||
for index := max(0, offset); index+len(target) <= len(value); index++ {
|
||||
matches := true
|
||||
for targetIndex := range target {
|
||||
if value[index+targetIndex] != target[targetIndex] {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func editorMarkdownStyleForToken(token chroma.TokenType) editorMarkdownStyle {
|
||||
switch {
|
||||
case token == chroma.GenericHeading || token == chroma.GenericSubheading:
|
||||
return editorMarkdownHeading
|
||||
case token == chroma.GenericStrong:
|
||||
return editorMarkdownStrong
|
||||
case token == chroma.GenericEmph:
|
||||
return editorMarkdownEmphasis
|
||||
case token == chroma.LiteralStringBacktick:
|
||||
return editorMarkdownCode
|
||||
case token == chroma.NameTag:
|
||||
return editorMarkdownLink
|
||||
case token == chroma.NameAttribute:
|
||||
return editorMarkdownDestination
|
||||
case token == chroma.Keyword:
|
||||
return editorMarkdownQuote
|
||||
case token.InSubCategory(chroma.Comment):
|
||||
return editorMarkdownComment
|
||||
default:
|
||||
return editorMarkdownPlain
|
||||
}
|
||||
}
|
||||
|
||||
func editorMarkdownStyleStart(style editorMarkdownStyle) string {
|
||||
if style == editorMarkdownPlain {
|
||||
return ""
|
||||
}
|
||||
if currentThemeName == "no-color" {
|
||||
switch style {
|
||||
case editorMarkdownHeading, editorMarkdownStrong:
|
||||
return "\x1b[1m"
|
||||
case editorMarkdownEmphasis:
|
||||
return "\x1b[3m"
|
||||
case editorMarkdownLink:
|
||||
return "\x1b[4m"
|
||||
case editorMarkdownComment:
|
||||
return "\x1b[2m"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if currentThemeName == "light" {
|
||||
switch style {
|
||||
case editorMarkdownHeading:
|
||||
return "\x1b[1;38;2;154;103;0m"
|
||||
case editorMarkdownStrong:
|
||||
return "\x1b[1;38;2;130;80;223m"
|
||||
case editorMarkdownEmphasis:
|
||||
return "\x1b[3;38;2;87;96;106m"
|
||||
case editorMarkdownCode:
|
||||
return "\x1b[38;2;17;99;41m"
|
||||
case editorMarkdownLink:
|
||||
return "\x1b[4;38;2;9;105;218m"
|
||||
case editorMarkdownDestination:
|
||||
return "\x1b[38;2;10;112;111m"
|
||||
case editorMarkdownQuote:
|
||||
return "\x1b[38;2;154;103;0m"
|
||||
case editorMarkdownComment:
|
||||
return "\x1b[2;38;2;101;109;118m"
|
||||
}
|
||||
}
|
||||
switch style {
|
||||
case editorMarkdownHeading:
|
||||
return "\x1b[1;38;2;240;183;47m"
|
||||
case editorMarkdownStrong:
|
||||
return "\x1b[1;38;2;198;120;221m"
|
||||
case editorMarkdownEmphasis:
|
||||
return "\x1b[3;38;2;215;218;232m"
|
||||
case editorMarkdownCode:
|
||||
return "\x1b[38;2;152;195;121m"
|
||||
case editorMarkdownLink:
|
||||
return "\x1b[4;38;2;97;175;239m"
|
||||
case editorMarkdownDestination:
|
||||
return "\x1b[38;2;86;182;194m"
|
||||
case editorMarkdownQuote:
|
||||
return "\x1b[38;2;229;192;123m"
|
||||
case editorMarkdownComment:
|
||||
return "\x1b[2;38;2;119;119;119m"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func editorMarkdownStyleEnd(active bool) string {
|
||||
foreground := "\x1b[39m"
|
||||
if active {
|
||||
switch currentThemeName {
|
||||
case "dark":
|
||||
foreground = "\x1b[38;2;215;218;232m"
|
||||
case "light":
|
||||
foreground = "\x1b[38;2;36;41;47m"
|
||||
case "high-contrast":
|
||||
foreground = "\x1b[38;2;255;255;255m"
|
||||
}
|
||||
}
|
||||
return "\x1b[22;23;24m" + foreground
|
||||
}
|
||||
472
pr_editor.go
Normal file
472
pr_editor.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
prEditTitleField = iota
|
||||
prEditBaseField
|
||||
prEditBodyField
|
||||
prEditFieldCount
|
||||
)
|
||||
|
||||
func (m *App) startPREdit() tea.Cmd {
|
||||
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
|
||||
m.err = errors.New(reason)
|
||||
return nil
|
||||
}
|
||||
m.writeMode = writePREdit
|
||||
m.prEditField = prEditBodyField
|
||||
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
|
||||
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
|
||||
m.prEditEditors[prEditBodyField] = newTextEditor(
|
||||
normalizeLineEndings(m.details.Body),
|
||||
m.editorMode == "vim",
|
||||
)
|
||||
m.prEditEditors[prEditBodyField].highlightMarkdown = true
|
||||
for index := range m.prEditEditors {
|
||||
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
|
||||
}
|
||||
m.prEditOriginal = m.currentPRMetadata()
|
||||
m.prEditBranches = nil
|
||||
m.prEditBranchesLoading = false
|
||||
m.prEditBranchesError = ""
|
||||
m.prEditBranchIndex = 0
|
||||
m.scroll = 0
|
||||
m.err = m.prEditEditors[m.prEditField].err
|
||||
m.prEditEditors[m.prEditField].err = nil
|
||||
m.ensurePREditCursorVisible()
|
||||
return m.loadPREditBranches()
|
||||
}
|
||||
|
||||
func (m *App) loadPREditBranches() tea.Cmd {
|
||||
service, ok := m.service.(GitHubBranchService)
|
||||
if !ok {
|
||||
m.prEditBranchesError = "configured GitHub service cannot list branches"
|
||||
return nil
|
||||
}
|
||||
m.prEditBranchesLoading = true
|
||||
owner, repo := m.details.Owner, m.details.Repository
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
branches, err := service.ListBranches(ctx, owner, repo)
|
||||
return branchesLoadedMsg{owner: owner, repo: repo, branches: branches, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) pullRequestUpdateUnavailable() string {
|
||||
if m.loading {
|
||||
return "pull request update unavailable while PR data is refreshing"
|
||||
}
|
||||
if m.details.FromCache {
|
||||
return "pull request update unavailable from an offline cached snapshot"
|
||||
}
|
||||
if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
|
||||
return "configured GitHub service does not support pull request updates"
|
||||
}
|
||||
if m.details.ID == "" {
|
||||
return "pull request details are not loaded"
|
||||
}
|
||||
if !m.details.Permissions.CanUpdatePR {
|
||||
return "GitHub did not grant update permission for this pull request"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := key.String()
|
||||
editorWidth := m.prEditEditorWidth()
|
||||
if m.writeMode == writePREditConfirm {
|
||||
switch k {
|
||||
case "y":
|
||||
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
|
||||
m.writeMode = writePREdit
|
||||
m.err = errors.New(reason)
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
if m.prEditIsStale() {
|
||||
m.writeMode = writePREdit
|
||||
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
m.writeMode = writePREditBusy
|
||||
return m, m.submitPREdit()
|
||||
case "n", "esc":
|
||||
m.writeMode = writePREdit
|
||||
m.ensurePREditCursorVisible()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "ctrl+s":
|
||||
if err := m.validatePREdit(); err != nil {
|
||||
m.err = err
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
} else if m.prEditIsStale() {
|
||||
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
} else {
|
||||
m.writeMode = writePREditConfirm
|
||||
m.err = nil
|
||||
return m, nil
|
||||
}
|
||||
case "tab":
|
||||
if m.prEditField != prEditBaseField || !m.completeBranchSuggestion() {
|
||||
m.movePREditField(1)
|
||||
}
|
||||
case "shift+tab":
|
||||
m.movePREditField(-1)
|
||||
case "ctrl+n":
|
||||
if m.prEditField == prEditBaseField {
|
||||
m.moveBranchSuggestion(1)
|
||||
}
|
||||
case "ctrl+p":
|
||||
if m.prEditField == prEditBaseField {
|
||||
m.moveBranchSuggestion(-1)
|
||||
}
|
||||
case "ctrl+d", "ctrl+u":
|
||||
if m.prEditField == prEditBodyField {
|
||||
direction := 1
|
||||
if k == "ctrl+u" {
|
||||
direction = -1
|
||||
}
|
||||
delta := direction * max(1, m.dashboardViewportHeight()/2)
|
||||
m.prEditEditors[m.prEditField].movePage(delta, editorWidth)
|
||||
m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll())
|
||||
}
|
||||
case "enter":
|
||||
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
|
||||
break
|
||||
}
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "up":
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(-1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "down":
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "esc":
|
||||
editor := &m.prEditEditors[m.prEditField]
|
||||
if editor.Modal && editor.Mode != textEditorNormal {
|
||||
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
|
||||
} else {
|
||||
m.writeMode = writeNone
|
||||
m.clearPREdit()
|
||||
m.err = nil
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
default:
|
||||
editor := &m.prEditEditors[m.prEditField]
|
||||
before := editor.Text
|
||||
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
|
||||
if m.prEditField != prEditBodyField {
|
||||
editor.Text = normalizeSingleLine(editor.Text)
|
||||
editor.Cursor = clamp(editor.Cursor, 0, len([]rune(editor.Text)))
|
||||
}
|
||||
if m.prEditField == prEditBaseField && editor.Text != before {
|
||||
m.prEditBranchIndex = 0
|
||||
}
|
||||
}
|
||||
m.err = nil
|
||||
m.ensurePREditCursorVisible()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m App) prEditEditorWidth() int {
|
||||
return max(1, max(10, m.width-2)-4)
|
||||
}
|
||||
|
||||
func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
|
||||
if m.cursorOutput == nil {
|
||||
return
|
||||
}
|
||||
editor := m.prEditEditors[m.prEditField]
|
||||
if editor.Mode != textEditorInsert {
|
||||
return
|
||||
}
|
||||
_, cursorLine := m.dashboardEditLayout()
|
||||
screenRow := cursorLine - scroll
|
||||
if screenRow < 0 || screenRow >= viewportHeight {
|
||||
return
|
||||
}
|
||||
_, column := editorCursorVisualPosition(editor, m.prEditEditorWidth())
|
||||
// Rows and columns are one-based. Each editor row has a two-cell "│ "
|
||||
// context rail before its text.
|
||||
m.cursorOutput.SetCursor(true, column+3, screenRow+1)
|
||||
}
|
||||
|
||||
func (m App) submitPREdit() tea.Cmd {
|
||||
writer := m.service.(GitHubPullRequestWriteService)
|
||||
id := m.details.ID
|
||||
update := m.prEditMetadata()
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
metadata, err := writer.UpdatePullRequest(ctx, id, update)
|
||||
return pullRequestUpdatedMsg{metadata: metadata, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) validatePREdit() error {
|
||||
update := m.prEditMetadata()
|
||||
if update.Title == "" {
|
||||
return errors.New("pull request title cannot be empty")
|
||||
}
|
||||
if update.BaseRef == "" {
|
||||
return errors.New("target branch cannot be empty")
|
||||
}
|
||||
if len(m.prEditBranches) > 0 {
|
||||
found := false
|
||||
for _, branch := range m.prEditBranches {
|
||||
if branch.Name == update.BaseRef {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
|
||||
}
|
||||
}
|
||||
if samePRMetadata(update, m.prEditOriginal) {
|
||||
return errors.New("title, target branch, and description are unchanged")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m App) prEditIsStale() bool {
|
||||
return !samePRMetadata(m.currentPRMetadata(), m.prEditOriginal)
|
||||
}
|
||||
|
||||
func (m App) currentPRMetadata() PullRequestMetadata {
|
||||
return PullRequestMetadata{
|
||||
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
|
||||
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
|
||||
UpdatedAt: m.details.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) prEditMetadata() PullRequestMetadata {
|
||||
body := m.prEditEditors[prEditBodyField].Text
|
||||
if body == normalizeLineEndings(m.prEditOriginal.Body) {
|
||||
// Opening the editor must not turn mixed or CRLF line endings into an
|
||||
// apparent edit. Preserve the remote body exactly until its content is
|
||||
// actually changed.
|
||||
body = m.prEditOriginal.Body
|
||||
}
|
||||
return PullRequestMetadata{
|
||||
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
|
||||
Body: body,
|
||||
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
|
||||
}
|
||||
}
|
||||
|
||||
func samePRMetadata(left, right PullRequestMetadata) bool {
|
||||
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
|
||||
}
|
||||
|
||||
func (m *App) clearPREdit() {
|
||||
m.prEditField = 0
|
||||
m.prEditEditors = [3]textEditor{}
|
||||
m.prEditOriginal = PullRequestMetadata{}
|
||||
m.prEditBranches = nil
|
||||
m.prEditBranchesLoading = false
|
||||
m.prEditBranchesError = ""
|
||||
m.prEditBranchIndex = 0
|
||||
}
|
||||
|
||||
func (m *App) movePREditField(delta int) {
|
||||
m.prEditField = (m.prEditField + delta + prEditFieldCount) % prEditFieldCount
|
||||
}
|
||||
|
||||
func textLineStart(value string, cursor int) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
for cursor > 0 && runes[cursor-1] != '\n' {
|
||||
cursor--
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func textLineEnd(value string, cursor int) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
for cursor < len(runes) && runes[cursor] != '\n' {
|
||||
cursor++
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func moveTextCursorLine(value string, cursor, delta int) int {
|
||||
start := textLineStart(value, cursor)
|
||||
column := cursor - start
|
||||
if delta < 0 {
|
||||
if start == 0 {
|
||||
return cursor
|
||||
}
|
||||
previousEnd := start - 1
|
||||
previousStart := textLineStart(value, previousEnd)
|
||||
return min(previousStart+column, previousEnd)
|
||||
}
|
||||
end := textLineEnd(value, cursor)
|
||||
if end == len([]rune(value)) {
|
||||
return cursor
|
||||
}
|
||||
nextStart := end + 1
|
||||
nextEnd := textLineEnd(value, nextStart)
|
||||
return min(nextStart+column, nextEnd)
|
||||
}
|
||||
|
||||
func (m App) dashboardEditLines() []string {
|
||||
lines, _ := m.dashboardEditLayout()
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m App) dashboardEditLayout() ([]string, int) {
|
||||
width := max(10, m.width-2)
|
||||
cursorLine := 0
|
||||
lines := []string{
|
||||
titleStyle.Render(fmt.Sprintf("%s #%d", m.details.RepoWithOwner, m.details.Number)) +
|
||||
" " + warnStyle.Render("EDITING"),
|
||||
"",
|
||||
titleStyle.Render("Edit pull request"),
|
||||
dimStyle.Render("Raw Markdown is preserved in the description."),
|
||||
}
|
||||
if m.err != nil {
|
||||
errorWidth := max(1, width-2)
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(m.err.Error(), errorWidth, ""), errorWidth, false)
|
||||
lines = append(lines, "")
|
||||
for _, line := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, badStyle.Render(line))
|
||||
}
|
||||
}
|
||||
appendField := func(label string, field int) {
|
||||
lines = append(lines, "")
|
||||
start := len(lines)
|
||||
lines = append(lines, m.prEditFieldLines(label, field, width)...)
|
||||
if m.prEditField == field {
|
||||
cursorLine = start + 1 + editorCursorVisualLine(m.prEditEditors[field], max(1, width-4))
|
||||
}
|
||||
}
|
||||
appendField("title", prEditTitleField)
|
||||
appendField("target branch", prEditBaseField)
|
||||
appendField("description", prEditBodyField)
|
||||
help := "tab/shift-tab field • ctrl-s review • esc normal/cancel"
|
||||
if m.prEditEditors[prEditBodyField].Modal {
|
||||
help += " • i/a/s insert • v/V select • y copy • p paste • hjkl/wWbBeE move • ctrl-d/u page • fFtT + ; find"
|
||||
} else {
|
||||
help += " • arrows/home/end move • ctrl-d/u page • enter newline"
|
||||
}
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(help, width, ""), width, false)
|
||||
lines = append(lines, "")
|
||||
for _, line := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, dimStyle.Render(line))
|
||||
}
|
||||
return lines, cursorLine
|
||||
}
|
||||
|
||||
func (m App) prEditFieldLines(label string, field, width int) []string {
|
||||
active := m.prEditField == field
|
||||
editor := m.prEditEditors[field]
|
||||
prefix := " "
|
||||
if active {
|
||||
prefix = "▶ "
|
||||
}
|
||||
mode := editor.modeLabel()
|
||||
if mode != "" {
|
||||
label += " [" + mode + "]"
|
||||
}
|
||||
labelLine := dimStyle.Render(prefix + label)
|
||||
if active {
|
||||
labelLine = titleStyle.Render(prefix + label)
|
||||
}
|
||||
textWidth := max(1, width-4)
|
||||
rendered := renderTextEditor(editor, textWidth, active)
|
||||
lines := []string{labelLine}
|
||||
for _, line := range rendered {
|
||||
if line.active {
|
||||
// Style the rail and text as one row. Nesting the cursor or rail
|
||||
// style inside a background style emits resets that can erase the
|
||||
// remainder of wrapped terminal rows.
|
||||
lines = append(lines, editorLineStyle.Render("│ "+line.text))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, dimStyle.Render("│ ")+line.text)
|
||||
}
|
||||
if active && field == prEditBaseField {
|
||||
lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m *App) ensurePREditCursorVisible() {
|
||||
if m.writeMode != writePREdit {
|
||||
return
|
||||
}
|
||||
lines, cursorLine := m.dashboardEditLayout()
|
||||
height := m.dashboardViewportHeight()
|
||||
if m.prEditField == prEditTitleField && cursorLine < height {
|
||||
// The title is the first editable field. Returning to it should also
|
||||
// restore the dashboard/editor heading instead of pinning the title's
|
||||
// text row to the top and clipping its label.
|
||||
m.scroll = 0
|
||||
} else if contextTop := max(0, cursorLine-2); contextTop < m.scroll {
|
||||
// Keep the active field label and its rail visible above the cursor.
|
||||
m.scroll = contextTop
|
||||
} else if cursorLine >= m.scroll+height {
|
||||
m.scroll = cursorLine - height + 1
|
||||
}
|
||||
m.scroll = clamp(m.scroll, 0, max(0, len(lines)-height))
|
||||
}
|
||||
|
||||
func (m App) prEditConfirmationLines(width int) []string {
|
||||
update := m.prEditMetadata()
|
||||
lines := []string{titleStyle.Render("Update this pull request?"), ""}
|
||||
if update.Title != m.prEditOriginal.Title {
|
||||
lines = append(lines,
|
||||
dimStyle.Render("title"),
|
||||
ansi.Truncate(m.prEditOriginal.Title, width, "…"),
|
||||
"→ "+ansi.Truncate(update.Title, max(1, width-2), "…"),
|
||||
"",
|
||||
)
|
||||
}
|
||||
if update.BaseRef != m.prEditOriginal.BaseRef {
|
||||
lines = append(lines,
|
||||
dimStyle.Render("target branch"),
|
||||
m.prEditOriginal.BaseRef+" → "+update.BaseRef,
|
||||
"",
|
||||
)
|
||||
}
|
||||
if update.Body != m.prEditOriginal.Body {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"description changed • %d → %d characters",
|
||||
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
|
||||
), "")
|
||||
}
|
||||
lines = append(lines, warnStyle.Render("y submit • n/esc continue editing"))
|
||||
return lines
|
||||
}
|
||||
84
system_clipboard.go
Normal file
84
system_clipboard.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type textClipboard interface {
|
||||
ReadText() (string, error)
|
||||
WriteText(string) error
|
||||
}
|
||||
|
||||
type systemTextClipboard struct{}
|
||||
|
||||
func (systemTextClipboard) ReadText() (string, error) {
|
||||
command, args, err := clipboardCommand(false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := exec.Command(command, args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read system clipboard: %w", err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (systemTextClipboard) WriteText(value string) error {
|
||||
command, args, err := clipboardCommand(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
process := exec.Command(command, args...)
|
||||
process.Stdin = bytes.NewBufferString(value)
|
||||
if output, err := process.CombinedOutput(); err != nil {
|
||||
if len(output) > 0 {
|
||||
return fmt.Errorf("write system clipboard: %w: %s", err, bytes.TrimSpace(output))
|
||||
}
|
||||
return fmt.Errorf("write system clipboard: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clipboardCommand(write bool) (string, []string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
if write {
|
||||
return "pbcopy", nil, nil
|
||||
}
|
||||
return "pbpaste", nil, nil
|
||||
case "windows":
|
||||
script := "Get-Clipboard -Raw"
|
||||
if write {
|
||||
script = "$input | Set-Clipboard"
|
||||
}
|
||||
return "powershell.exe", []string{"-NoProfile", "-NonInteractive", "-Command", script}, nil
|
||||
default:
|
||||
type candidate struct {
|
||||
command string
|
||||
write []string
|
||||
read []string
|
||||
}
|
||||
candidates := []candidate{
|
||||
{command: "wl-copy", read: []string{"-n"}, write: nil},
|
||||
{command: "xclip", read: []string{"-selection", "clipboard", "-o"}, write: []string{"-selection", "clipboard", "-i"}},
|
||||
{command: "xsel", read: []string{"--clipboard", "--output"}, write: []string{"--clipboard", "--input"}},
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
command := candidate.command
|
||||
args := candidate.read
|
||||
if candidate.command == "wl-copy" && !write {
|
||||
command = "wl-paste"
|
||||
}
|
||||
if write {
|
||||
args = candidate.write
|
||||
}
|
||||
if _, err := exec.LookPath(command); err == nil {
|
||||
return command, args, nil
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("system clipboard unavailable: install wl-clipboard, xclip, or xsel")
|
||||
}
|
||||
}
|
||||
86
terminal_cursor.go
Normal file
86
terminal_cursor.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// terminalCursorOutput decorates Bubble Tea's completed frame writes with a
|
||||
// hardware cursor position. Bubble Tea otherwise parks the cursor at the
|
||||
// bottom of every frame, which prevents a real insertion caret inside a custom
|
||||
// editor.
|
||||
type terminalCursorOutput struct {
|
||||
file *os.File
|
||||
|
||||
mu sync.Mutex
|
||||
visible bool
|
||||
column int
|
||||
row int
|
||||
}
|
||||
|
||||
func newTerminalCursorOutput(file *os.File) *terminalCursorOutput {
|
||||
return &terminalCursorOutput{file: file}
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) SetCursor(visible bool, column, row int) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.visible, o.column, o.row = visible, column, row
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) FrameMarker() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if !o.visible {
|
||||
return ""
|
||||
}
|
||||
// This zero-width sequence makes frames at different insertion positions
|
||||
// distinct, preventing Bubble Tea from skipping a hardware-cursor-only
|
||||
// update. The output wrapper reasserts the same position after Bubble Tea
|
||||
// parks its cursor at the bottom of the frame.
|
||||
return ansi.CursorPosition(o.column, o.row)
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) Write(value []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
written, err := o.file.Write(value)
|
||||
if err != nil || written != len(value) {
|
||||
return written, err
|
||||
}
|
||||
// Let Bubble Tea restore the cursor normally during startup/shutdown.
|
||||
if bytes.Equal(value, []byte(ansi.ShowCursor)) || bytes.Equal(value, []byte(ansi.HideCursor)) {
|
||||
if bytes.Equal(value, []byte(ansi.ShowCursor)) {
|
||||
_, _ = io.WriteString(o.file, ansi.SetCursorStyle(0))
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
if !o.visible {
|
||||
_, err = io.WriteString(o.file, ansi.HideCursor)
|
||||
return written, err
|
||||
}
|
||||
_, err = io.WriteString(
|
||||
o.file,
|
||||
ansi.SetCursorStyle(5)+
|
||||
ansi.CursorPosition(o.column, o.row)+
|
||||
ansi.ShowCursor,
|
||||
)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) Read(value []byte) (int, error) {
|
||||
return o.file.Read(value)
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *terminalCursorOutput) Fd() uintptr {
|
||||
return o.file.Fd()
|
||||
}
|
||||
52
terminal_cursor_test.go
Normal file
52
terminal_cursor_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestTerminalCursorOutputPositionsHardwareBarAfterFrame(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
output := newTerminalCursorOutput(file)
|
||||
output.SetCursor(true, 7, 4)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSuffix := ansi.SetCursorStyle(5) + ansi.CursorPosition(7, 4) + ansi.ShowCursor
|
||||
if !strings.HasSuffix(string(content), wantSuffix) {
|
||||
t.Fatalf("cursor output = %q, want suffix %q", content, wantSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalCursorOutputHidesCursorOutsideInsertMode(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
output := newTerminalCursorOutput(file)
|
||||
output.SetCursor(false, 0, 0)
|
||||
if _, err := output.Write([]byte("frame")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(file.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(string(content), ansi.HideCursor) {
|
||||
t.Fatalf("cursor output did not hide cursor: %q", content)
|
||||
}
|
||||
}
|
||||
938
text_editor.go
Normal file
938
text_editor.go
Normal file
@@ -0,0 +1,938 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type textEditorMode string
|
||||
|
||||
const (
|
||||
textEditorNormal textEditorMode = "NORMAL"
|
||||
textEditorInsert textEditorMode = "INSERT"
|
||||
textEditorVisual textEditorMode = "VISUAL"
|
||||
)
|
||||
|
||||
type textFind struct {
|
||||
command rune
|
||||
target rune
|
||||
valid bool
|
||||
}
|
||||
|
||||
// textEditor owns buffer and motion state independently of any particular
|
||||
// screen. Inputs can opt into modal behavior without duplicating cursor logic.
|
||||
type textEditor struct {
|
||||
Text string
|
||||
Cursor int
|
||||
Modal bool
|
||||
Mode textEditorMode
|
||||
pendingFind rune
|
||||
pendingG bool
|
||||
lastFind textFind
|
||||
visualAnchor int
|
||||
visualLine bool
|
||||
clipboard textClipboard
|
||||
err error
|
||||
hardwareCursor bool
|
||||
highlightMarkdown bool
|
||||
}
|
||||
|
||||
func newTextEditor(text string, modal bool) textEditor {
|
||||
editor := textEditor{
|
||||
Text: text, Modal: modal, Mode: textEditorInsert,
|
||||
clipboard: systemTextClipboard{},
|
||||
}
|
||||
editor.Cursor = len([]rune(text))
|
||||
if modal {
|
||||
editor.Mode = textEditorNormal
|
||||
editor.Cursor = 0
|
||||
}
|
||||
return editor
|
||||
}
|
||||
|
||||
func (e *textEditor) handleKey(key tea.KeyMsg, multiline bool) bool {
|
||||
return e.handleKeyAtWidth(key, multiline, 0)
|
||||
}
|
||||
|
||||
func (e *textEditor) handleKeyAtWidth(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
|
||||
if !e.Modal {
|
||||
return e.handleStandardKey(key, multiline, wrapWidth)
|
||||
}
|
||||
if e.Mode == textEditorInsert {
|
||||
if key.String() == "esc" {
|
||||
e.Mode = textEditorNormal
|
||||
e.clearPending()
|
||||
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
if e.Cursor > start {
|
||||
e.Cursor--
|
||||
}
|
||||
return true
|
||||
}
|
||||
return e.handleStandardKey(key, multiline, wrapWidth)
|
||||
}
|
||||
if e.Mode == textEditorVisual {
|
||||
return e.handleVisualKey(key, multiline, wrapWidth)
|
||||
}
|
||||
return e.handleNormalKey(key, multiline, wrapWidth)
|
||||
}
|
||||
|
||||
func (e *textEditor) movePage(delta, wrapWidth int) {
|
||||
normalCursor := e.Mode != textEditorInsert
|
||||
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, delta, wrapWidth, normalCursor)
|
||||
e.clearPending()
|
||||
}
|
||||
|
||||
func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
|
||||
switch key.String() {
|
||||
case "left":
|
||||
e.Cursor = max(0, e.Cursor-1)
|
||||
case "right":
|
||||
e.Cursor = min(len([]rune(e.Text)), e.Cursor+1)
|
||||
case "up":
|
||||
if !multiline {
|
||||
return false
|
||||
}
|
||||
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false)
|
||||
case "down":
|
||||
if !multiline {
|
||||
return false
|
||||
}
|
||||
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false)
|
||||
case "home", "ctrl+a":
|
||||
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
case "end", "ctrl+e":
|
||||
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
case "backspace":
|
||||
e.deleteBefore()
|
||||
case "delete":
|
||||
e.deleteAt()
|
||||
case "enter":
|
||||
if !multiline {
|
||||
return false
|
||||
}
|
||||
e.insert("\n")
|
||||
default:
|
||||
if key.Type == tea.KeyRunes {
|
||||
e.insert(string(key.Runes))
|
||||
} else if key.Type == tea.KeySpace {
|
||||
e.insert(" ")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
|
||||
if e.pendingFind != 0 {
|
||||
if key.Type == tea.KeyRunes && len(key.Runes) > 0 {
|
||||
command := e.pendingFind
|
||||
e.pendingFind = 0
|
||||
e.performFind(command, key.Runes[0], true, wrapWidth)
|
||||
return true
|
||||
}
|
||||
if key.Type == tea.KeySpace {
|
||||
command := e.pendingFind
|
||||
e.pendingFind = 0
|
||||
e.performFind(command, ' ', true, wrapWidth)
|
||||
return true
|
||||
}
|
||||
e.pendingFind = 0
|
||||
}
|
||||
if e.pendingG {
|
||||
e.pendingG = false
|
||||
if key.String() == "g" {
|
||||
e.Cursor = firstNonBlank(e.Text, 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
e.clearPending()
|
||||
return false
|
||||
case "i":
|
||||
e.Mode = textEditorInsert
|
||||
case "a":
|
||||
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
e.Cursor = min(end, e.Cursor+1)
|
||||
e.Mode = textEditorInsert
|
||||
case "I":
|
||||
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
|
||||
e.Mode = textEditorInsert
|
||||
case "A":
|
||||
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
e.Mode = textEditorInsert
|
||||
case "o":
|
||||
if !multiline {
|
||||
return false
|
||||
}
|
||||
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
e.insert("\n")
|
||||
e.Mode = textEditorInsert
|
||||
case "O":
|
||||
if !multiline {
|
||||
return false
|
||||
}
|
||||
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
e.Cursor = start
|
||||
e.insert("\n")
|
||||
e.Cursor = start
|
||||
e.Mode = textEditorInsert
|
||||
case "h", "left":
|
||||
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
e.Cursor = max(start, e.Cursor-1)
|
||||
case "l", "right":
|
||||
e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1)
|
||||
case "j", "down":
|
||||
if multiline {
|
||||
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true)
|
||||
}
|
||||
case "k", "up":
|
||||
if multiline {
|
||||
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true)
|
||||
}
|
||||
case "0", "home":
|
||||
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
case "^":
|
||||
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
|
||||
case "$", "end":
|
||||
e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth)
|
||||
case "w":
|
||||
e.Cursor = nextWordStart(e.Text, e.Cursor, false)
|
||||
case "W":
|
||||
e.Cursor = nextWordStart(e.Text, e.Cursor, true)
|
||||
case "b":
|
||||
e.Cursor = previousWordStart(e.Text, e.Cursor, false)
|
||||
case "B":
|
||||
e.Cursor = previousWordStart(e.Text, e.Cursor, true)
|
||||
case "e":
|
||||
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth)
|
||||
case "E":
|
||||
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth)
|
||||
case "g":
|
||||
e.pendingG = true
|
||||
case "G":
|
||||
e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text)))
|
||||
case "v":
|
||||
e.startVisual(false)
|
||||
case "V":
|
||||
e.startVisual(true)
|
||||
case "p":
|
||||
e.pasteClipboard(false, wrapWidth)
|
||||
case "s":
|
||||
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
if e.Cursor < end {
|
||||
e.deleteAt()
|
||||
}
|
||||
e.Mode = textEditorInsert
|
||||
case "f", "F", "t", "T":
|
||||
e.pendingFind = []rune(key.String())[0]
|
||||
case ";":
|
||||
if e.lastFind.valid {
|
||||
e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth)
|
||||
}
|
||||
case ",":
|
||||
if e.lastFind.valid {
|
||||
e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth)
|
||||
}
|
||||
case "x", "delete":
|
||||
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
if e.Cursor < end {
|
||||
e.deleteAt()
|
||||
}
|
||||
case "X", "backspace":
|
||||
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
||||
if e.Cursor > start {
|
||||
e.deleteBefore()
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool {
|
||||
if e.pendingFind != 0 || e.pendingG {
|
||||
e.Mode = textEditorNormal
|
||||
handled := e.handleNormalKey(key, multiline, wrapWidth)
|
||||
e.Mode = textEditorVisual
|
||||
return handled
|
||||
}
|
||||
switch key.String() {
|
||||
case "esc", "v":
|
||||
e.stopVisual()
|
||||
case "V":
|
||||
if e.visualLine {
|
||||
e.stopVisual()
|
||||
} else {
|
||||
e.visualLine = true
|
||||
}
|
||||
case "o":
|
||||
e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor
|
||||
case "y":
|
||||
e.yankSelection(wrapWidth)
|
||||
case "d", "x", "delete":
|
||||
e.deleteSelection(wrapWidth)
|
||||
case "p":
|
||||
e.pasteClipboard(true, wrapWidth)
|
||||
default:
|
||||
if !isVisualMotion(key.String()) {
|
||||
return false
|
||||
}
|
||||
e.Mode = textEditorNormal
|
||||
handled := e.handleNormalKey(key, multiline, wrapWidth)
|
||||
e.Mode = textEditorVisual
|
||||
return handled
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isVisualMotion(key string) bool {
|
||||
switch key {
|
||||
case "h", "j", "k", "l", "left", "down", "up", "right",
|
||||
"0", "^", "$", "home", "end",
|
||||
"w", "W", "b", "B", "e", "E", "g", "G",
|
||||
"f", "F", "t", "T", ";", ",":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *textEditor) startVisual(linewise bool) {
|
||||
e.Mode = textEditorVisual
|
||||
e.visualAnchor = e.Cursor
|
||||
e.visualLine = linewise
|
||||
e.clearPending()
|
||||
}
|
||||
|
||||
func (e *textEditor) stopVisual() {
|
||||
e.Mode = textEditorNormal
|
||||
e.visualLine = false
|
||||
e.clearPending()
|
||||
}
|
||||
|
||||
func (e textEditor) selectionBounds(wrapWidth int) (int, int, bool) {
|
||||
if e.Mode != textEditorVisual {
|
||||
return 0, 0, false
|
||||
}
|
||||
runes := []rune(e.Text)
|
||||
anchor := clamp(e.visualAnchor, 0, len(runes))
|
||||
cursor := clamp(e.Cursor, 0, len(runes))
|
||||
if !e.visualLine {
|
||||
start, end := min(anchor, cursor), min(len(runes), max(anchor, cursor)+1)
|
||||
return start, end, end > start
|
||||
}
|
||||
anchorStart, anchorEnd := editorLineBounds(e.Text, anchor, wrapWidth)
|
||||
cursorStart, cursorEnd := editorLineBounds(e.Text, cursor, wrapWidth)
|
||||
start, end := min(anchorStart, cursorStart), max(anchorEnd, cursorEnd)
|
||||
if end < len(runes) && runes[end] == '\n' {
|
||||
end++
|
||||
}
|
||||
return start, end, end > start
|
||||
}
|
||||
|
||||
func (e *textEditor) yankSelection(wrapWidth int) {
|
||||
start, end, ok := e.selectionBounds(wrapWidth)
|
||||
if !ok {
|
||||
e.stopVisual()
|
||||
return
|
||||
}
|
||||
if e.clipboard == nil {
|
||||
e.clipboard = systemTextClipboard{}
|
||||
}
|
||||
if err := e.clipboard.WriteText(string([]rune(e.Text)[start:end])); err != nil {
|
||||
e.err = err
|
||||
return
|
||||
}
|
||||
e.Cursor = start
|
||||
e.stopVisual()
|
||||
}
|
||||
|
||||
func (e *textEditor) deleteSelection(wrapWidth int) {
|
||||
start, end, ok := e.selectionBounds(wrapWidth)
|
||||
if !ok {
|
||||
e.stopVisual()
|
||||
return
|
||||
}
|
||||
runes := []rune(e.Text)
|
||||
e.Text = string(append(runes[:start], runes[end:]...))
|
||||
e.Cursor = min(start, len([]rune(e.Text)))
|
||||
if e.Cursor == len([]rune(e.Text)) && e.Cursor > 0 {
|
||||
e.Cursor--
|
||||
}
|
||||
e.stopVisual()
|
||||
}
|
||||
|
||||
func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) {
|
||||
if e.clipboard == nil {
|
||||
e.clipboard = systemTextClipboard{}
|
||||
}
|
||||
value, err := e.clipboard.ReadText()
|
||||
if err != nil {
|
||||
e.err = err
|
||||
return
|
||||
}
|
||||
value = normalizeLineEndings(value)
|
||||
if replaceSelection {
|
||||
start, end, ok := e.selectionBounds(wrapWidth)
|
||||
if ok {
|
||||
runes := []rune(e.Text)
|
||||
e.Text = string(append(runes[:start], runes[end:]...))
|
||||
e.Cursor = start
|
||||
}
|
||||
e.stopVisual()
|
||||
} else {
|
||||
_, end := editorLineBounds(e.Text, e.Cursor, 0)
|
||||
e.Cursor = min(end, e.Cursor+1)
|
||||
}
|
||||
e.insert(value)
|
||||
if e.Cursor > 0 {
|
||||
e.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
func (e *textEditor) clearPending() {
|
||||
e.pendingFind = 0
|
||||
e.pendingG = false
|
||||
}
|
||||
|
||||
func (e *textEditor) insert(text string) {
|
||||
value := []rune(e.Text)
|
||||
insert := []rune(text)
|
||||
cursor := clamp(e.Cursor, 0, len(value))
|
||||
updated := make([]rune, 0, len(value)+len(insert))
|
||||
updated = append(updated, value[:cursor]...)
|
||||
updated = append(updated, insert...)
|
||||
updated = append(updated, value[cursor:]...)
|
||||
e.Text = string(updated)
|
||||
e.Cursor = cursor + len(insert)
|
||||
}
|
||||
|
||||
func (e *textEditor) deleteBefore() {
|
||||
value := []rune(e.Text)
|
||||
e.Cursor = clamp(e.Cursor, 0, len(value))
|
||||
if e.Cursor == 0 {
|
||||
return
|
||||
}
|
||||
value = append(value[:e.Cursor-1], value[e.Cursor:]...)
|
||||
e.Cursor--
|
||||
e.Text = string(value)
|
||||
}
|
||||
|
||||
func (e *textEditor) deleteAt() {
|
||||
value := []rune(e.Text)
|
||||
e.Cursor = clamp(e.Cursor, 0, len(value))
|
||||
if e.Cursor == len(value) {
|
||||
return
|
||||
}
|
||||
value = append(value[:e.Cursor], value[e.Cursor+1:]...)
|
||||
e.Text = string(value)
|
||||
}
|
||||
|
||||
func (e *textEditor) performFind(command, target rune, remember bool, wrapWidth int) {
|
||||
runes := []rune(e.Text)
|
||||
cursor := clamp(e.Cursor, 0, len(runes))
|
||||
start, end := editorLineBounds(e.Text, cursor, wrapWidth)
|
||||
found := -1
|
||||
switch command {
|
||||
case 'f', 't':
|
||||
searchStart := cursor + 1
|
||||
if !remember && command == 't' {
|
||||
searchStart++
|
||||
}
|
||||
for index := min(searchStart, end); index < end; index++ {
|
||||
if runes[index] == target {
|
||||
found = index
|
||||
break
|
||||
}
|
||||
}
|
||||
case 'F', 'T':
|
||||
searchStart := cursor - 1
|
||||
if !remember && command == 'T' {
|
||||
searchStart--
|
||||
}
|
||||
for index := min(searchStart, end-1); index >= start; index-- {
|
||||
if runes[index] == target {
|
||||
found = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if found >= 0 {
|
||||
switch command {
|
||||
case 't':
|
||||
found = max(cursor, found-1)
|
||||
case 'T':
|
||||
found = min(cursor, found+1)
|
||||
}
|
||||
e.Cursor = found
|
||||
}
|
||||
if remember {
|
||||
e.lastFind = textFind{command: command, target: target, valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
func reverseFind(command rune) rune {
|
||||
switch command {
|
||||
case 'f':
|
||||
return 'F'
|
||||
case 'F':
|
||||
return 'f'
|
||||
case 't':
|
||||
return 'T'
|
||||
default:
|
||||
return 't'
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonBlank(value string, cursor int) int {
|
||||
return firstNonBlankAtWidth(value, cursor, 0)
|
||||
}
|
||||
|
||||
func firstNonBlankAtWidth(value string, cursor, wrapWidth int) int {
|
||||
runes := []rune(value)
|
||||
start, end := editorLineBounds(value, cursor, wrapWidth)
|
||||
for start < end && unicode.IsSpace(runes[start]) {
|
||||
start++
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func normalLineLast(value string, cursor int) int {
|
||||
return normalEditorLineLast(value, cursor, 0)
|
||||
}
|
||||
|
||||
func normalEditorLineLast(value string, cursor, wrapWidth int) int {
|
||||
start, end := editorLineBounds(value, cursor, wrapWidth)
|
||||
if end > start {
|
||||
return end - 1
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func moveNormalCursorLine(value string, cursor, delta int) int {
|
||||
return moveEditorCursorLine(value, cursor, delta, 0, true)
|
||||
}
|
||||
|
||||
func nextWordStart(value string, cursor int, big bool) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
if cursor == len(runes) {
|
||||
return cursor
|
||||
}
|
||||
if big {
|
||||
for cursor < len(runes) && !unicode.IsSpace(runes[cursor]) {
|
||||
cursor++
|
||||
}
|
||||
} else {
|
||||
category := wordCategory(runes[cursor])
|
||||
for cursor < len(runes) && wordCategory(runes[cursor]) == category {
|
||||
cursor++
|
||||
}
|
||||
}
|
||||
for cursor < len(runes) && unicode.IsSpace(runes[cursor]) {
|
||||
cursor++
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func previousWordStart(value string, cursor int, big bool) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
if cursor == 0 {
|
||||
return 0
|
||||
}
|
||||
cursor--
|
||||
for cursor > 0 && unicode.IsSpace(runes[cursor]) {
|
||||
cursor--
|
||||
}
|
||||
if big {
|
||||
for cursor > 0 && !unicode.IsSpace(runes[cursor-1]) {
|
||||
cursor--
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
category := wordCategory(runes[cursor])
|
||||
for cursor > 0 && wordCategory(runes[cursor-1]) == category {
|
||||
cursor--
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func wordEnd(value string, cursor int, big bool) int {
|
||||
return wordEndAtWidth(value, cursor, big, 0)
|
||||
}
|
||||
|
||||
func wordEndAtWidth(value string, cursor int, big bool, wrapWidth int) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
if cursor == len(runes) {
|
||||
return cursor
|
||||
}
|
||||
original := cursor
|
||||
for {
|
||||
_, end := editorLineBounds(value, cursor, wrapWidth)
|
||||
if candidate, found := wordEndWithin(runes, cursor, end, big); found {
|
||||
return candidate
|
||||
}
|
||||
|
||||
next := end
|
||||
if next < len(runes) && runes[next] == '\n' {
|
||||
next++
|
||||
}
|
||||
if next >= len(runes) || next <= cursor {
|
||||
return original
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
func wordEndWithin(runes []rune, cursor, end int, big bool) (int, bool) {
|
||||
cursor = clamp(cursor, 0, min(end, len(runes)))
|
||||
if cursor >= end {
|
||||
return cursor, false
|
||||
}
|
||||
if unicode.IsSpace(runes[cursor]) {
|
||||
for cursor < end && unicode.IsSpace(runes[cursor]) {
|
||||
cursor++
|
||||
}
|
||||
if cursor >= end {
|
||||
return cursor, false
|
||||
}
|
||||
} else if cursor+1 >= end || wordEndCategory(runes[cursor+1], big) != wordEndCategory(runes[cursor], big) {
|
||||
cursor++
|
||||
for cursor < end && unicode.IsSpace(runes[cursor]) {
|
||||
cursor++
|
||||
}
|
||||
if cursor >= end {
|
||||
return cursor, false
|
||||
}
|
||||
}
|
||||
|
||||
category := wordEndCategory(runes[cursor], big)
|
||||
for cursor+1 < end && wordEndCategory(runes[cursor+1], big) == category {
|
||||
cursor++
|
||||
}
|
||||
return cursor, true
|
||||
}
|
||||
|
||||
func wordEndCategory(value rune, big bool) int {
|
||||
if big {
|
||||
if unicode.IsSpace(value) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return wordCategory(value)
|
||||
}
|
||||
|
||||
func wordCategory(value rune) int {
|
||||
if unicode.IsSpace(value) {
|
||||
return 0
|
||||
}
|
||||
if value == '_' || unicode.IsLetter(value) || unicode.IsNumber(value) {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func (e textEditor) modeLabel() string {
|
||||
if !e.Modal {
|
||||
return ""
|
||||
}
|
||||
return string(e.Mode)
|
||||
}
|
||||
|
||||
func normalizeSingleLine(value string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(value)
|
||||
}
|
||||
|
||||
func normalizeLineEndings(value string) string {
|
||||
value = strings.ReplaceAll(value, "\r\n", "\n")
|
||||
return strings.ReplaceAll(value, "\r", "\n")
|
||||
}
|
||||
|
||||
type editorVisualLine struct {
|
||||
text string
|
||||
start, end int
|
||||
logicalStart, logicalEnd int
|
||||
}
|
||||
|
||||
type editorRenderedLine struct {
|
||||
text string
|
||||
active bool
|
||||
}
|
||||
|
||||
func editorVisualLines(value string, width int) []editorVisualLine {
|
||||
runes := []rune(value)
|
||||
var visual []editorVisualLine
|
||||
logicalStart := 0
|
||||
for index := 0; index <= len(runes); index++ {
|
||||
if index < len(runes) && runes[index] != '\n' {
|
||||
continue
|
||||
}
|
||||
visual = append(visual, wrapEditorLogicalLine(runes, logicalStart, index, max(1, width))...)
|
||||
logicalStart = index + 1
|
||||
}
|
||||
if len(visual) == 0 {
|
||||
return []editorVisualLine{{}}
|
||||
}
|
||||
return visual
|
||||
}
|
||||
|
||||
func editorVisualLineIndex(lines []editorVisualLine, cursor int) int {
|
||||
for index, line := range lines {
|
||||
if cursor >= line.start && cursor < line.end {
|
||||
return index
|
||||
}
|
||||
if line.start == line.end && cursor == line.start {
|
||||
return index
|
||||
}
|
||||
if cursor == line.logicalEnd && line.end == line.logicalEnd {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return max(0, len(lines)-1)
|
||||
}
|
||||
|
||||
func editorLineBounds(value string, cursor, wrapWidth int) (int, int) {
|
||||
if wrapWidth <= 0 {
|
||||
return textLineStart(value, cursor), textLineEnd(value, cursor)
|
||||
}
|
||||
lines := editorVisualLines(value, wrapWidth)
|
||||
line := lines[editorVisualLineIndex(lines, clamp(cursor, 0, len([]rune(value))))]
|
||||
return line.start, line.end
|
||||
}
|
||||
|
||||
func moveEditorCursorLine(value string, cursor, delta, wrapWidth int, normal bool) int {
|
||||
if wrapWidth <= 0 {
|
||||
moved := moveTextCursorLine(value, cursor, delta)
|
||||
if normal {
|
||||
return min(moved, normalLineLast(value, moved))
|
||||
}
|
||||
return moved
|
||||
}
|
||||
runes := []rune(value)
|
||||
lines := editorVisualLines(value, wrapWidth)
|
||||
index := editorVisualLineIndex(lines, clamp(cursor, 0, len(runes)))
|
||||
targetIndex := clamp(index+delta, 0, len(lines)-1)
|
||||
if targetIndex == index {
|
||||
return cursor
|
||||
}
|
||||
column := lipgloss.Width(string(runes[lines[index].start:clamp(cursor, lines[index].start, lines[index].end)]))
|
||||
target := lines[targetIndex]
|
||||
position, usedWidth := target.start, 0
|
||||
for position < target.end {
|
||||
runeWidth := lipgloss.Width(string(runes[position]))
|
||||
if usedWidth+runeWidth > column {
|
||||
break
|
||||
}
|
||||
usedWidth += runeWidth
|
||||
position++
|
||||
}
|
||||
if normal && target.end > target.start {
|
||||
position = min(position, target.end-1)
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
func renderTextEditor(editor textEditor, width int, active bool) []editorRenderedLine {
|
||||
width = max(1, width)
|
||||
runes := []rune(editor.Text)
|
||||
cursor := clamp(editor.Cursor, 0, len(runes))
|
||||
visual := editorVisualLines(editor.Text, width)
|
||||
activeIndex := editorVisualLineIndex(visual, cursor)
|
||||
selectionStart, selectionEnd, hasSelection := editor.selectionBounds(width)
|
||||
var markdownStyles []editorMarkdownStyle
|
||||
if editor.highlightMarkdown {
|
||||
markdownStyles = editorMarkdownStyles(editor.Text)
|
||||
}
|
||||
|
||||
lines := make([]editorRenderedLine, 0, len(visual))
|
||||
for index, line := range visual {
|
||||
onVisualLine := index == activeIndex
|
||||
rendered := renderEditorVisualLine(
|
||||
line, cursor, editor.Mode, selectionStart, selectionEnd,
|
||||
active && hasSelection, active && onVisualLine, editor.hardwareCursor,
|
||||
markdownStyles, width,
|
||||
)
|
||||
if active && onVisualLine {
|
||||
rendered = pad(rendered, width)
|
||||
}
|
||||
lines = append(lines, editorRenderedLine{
|
||||
text: rendered,
|
||||
active: active && onVisualLine,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func editorCursorVisualLine(editor textEditor, width int) int {
|
||||
width = max(1, width)
|
||||
visual := editorVisualLines(editor.Text, width)
|
||||
return editorVisualLineIndex(visual, clamp(editor.Cursor, 0, len([]rune(editor.Text))))
|
||||
}
|
||||
|
||||
func editorCursorVisualPosition(editor textEditor, width int) (int, int) {
|
||||
width = max(1, width)
|
||||
runes := []rune(editor.Text)
|
||||
cursor := clamp(editor.Cursor, 0, len(runes))
|
||||
visual := editorVisualLines(editor.Text, width)
|
||||
index := editorVisualLineIndex(visual, cursor)
|
||||
line := visual[index]
|
||||
column := lipgloss.Width(string(runes[line.start:clamp(cursor, line.start, line.end)]))
|
||||
return index, column
|
||||
}
|
||||
|
||||
func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine {
|
||||
if start == end {
|
||||
return []editorVisualLine{{
|
||||
start: start, end: end, logicalStart: start, logicalEnd: end,
|
||||
}}
|
||||
}
|
||||
var lines []editorVisualLine
|
||||
for offset := start; offset < end; {
|
||||
next := offset
|
||||
lineWidth := 0
|
||||
for next < end {
|
||||
runeWidth := lipgloss.Width(string(runes[next]))
|
||||
if next > offset && lineWidth+runeWidth > width {
|
||||
break
|
||||
}
|
||||
lineWidth += runeWidth
|
||||
next++
|
||||
if lineWidth >= width {
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == offset {
|
||||
next++
|
||||
}
|
||||
lines = append(lines, editorVisualLine{
|
||||
text: string(runes[offset:next]), start: offset, end: next,
|
||||
logicalStart: start, logicalEnd: end,
|
||||
})
|
||||
offset = next
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderEditorVisualLine(
|
||||
line editorVisualLine,
|
||||
cursor int,
|
||||
mode textEditorMode,
|
||||
selectionStart, selectionEnd int,
|
||||
hasSelection, showCursor, hardwareCursor bool,
|
||||
markdownStyles []editorMarkdownStyle,
|
||||
width int,
|
||||
) string {
|
||||
const (
|
||||
reverseStart = "\x1b[7m"
|
||||
reverseEnd = "\x1b[27m"
|
||||
underlineStart = "\x1b[4m"
|
||||
underlineEnd = "\x1b[24m"
|
||||
)
|
||||
runes := []rune(line.text)
|
||||
var rendered strings.Builder
|
||||
selected := false
|
||||
markdownStyle := editorMarkdownPlain
|
||||
for offset, value := range runes {
|
||||
position := line.start + offset
|
||||
nextMarkdownStyle := editorMarkdownPlain
|
||||
if position < len(markdownStyles) {
|
||||
nextMarkdownStyle = markdownStyles[position]
|
||||
}
|
||||
if nextMarkdownStyle != markdownStyle {
|
||||
if markdownStyle != editorMarkdownPlain {
|
||||
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
|
||||
}
|
||||
if nextMarkdownStyle != editorMarkdownPlain {
|
||||
rendered.WriteString(editorMarkdownStyleStart(nextMarkdownStyle))
|
||||
}
|
||||
markdownStyle = nextMarkdownStyle
|
||||
}
|
||||
nowSelected := hasSelection && position >= selectionStart && position < selectionEnd
|
||||
if nowSelected != selected {
|
||||
if nowSelected {
|
||||
rendered.WriteString(reverseStart)
|
||||
} else {
|
||||
rendered.WriteString(reverseEnd)
|
||||
}
|
||||
selected = nowSelected
|
||||
}
|
||||
if showCursor && position == cursor {
|
||||
switch mode {
|
||||
case textEditorInsert:
|
||||
if hardwareCursor {
|
||||
rendered.WriteRune(value)
|
||||
continue
|
||||
}
|
||||
rendered.WriteString(underlineStart)
|
||||
rendered.WriteRune(value)
|
||||
rendered.WriteString(underlineEnd)
|
||||
continue
|
||||
case textEditorVisual:
|
||||
rendered.WriteString(underlineStart)
|
||||
rendered.WriteRune(value)
|
||||
rendered.WriteString(underlineEnd)
|
||||
continue
|
||||
default:
|
||||
if !selected {
|
||||
rendered.WriteString(reverseStart)
|
||||
}
|
||||
rendered.WriteRune(value)
|
||||
if !selected {
|
||||
rendered.WriteString(reverseEnd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
rendered.WriteRune(value)
|
||||
}
|
||||
if selected {
|
||||
rendered.WriteString(reverseEnd)
|
||||
}
|
||||
if markdownStyle != editorMarkdownPlain {
|
||||
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
|
||||
}
|
||||
if showCursor && cursor == line.end {
|
||||
switch mode {
|
||||
case textEditorInsert:
|
||||
if hardwareCursor {
|
||||
break
|
||||
}
|
||||
if lipgloss.Width(line.text) < width {
|
||||
rendered.WriteString(underlineStart + " " + underlineEnd)
|
||||
} else if len(runes) > 0 {
|
||||
value := rendered.String()
|
||||
rendered.Reset()
|
||||
rendered.WriteString(ansi.Truncate(value, max(0, width-1), ""))
|
||||
rendered.WriteString(underlineStart)
|
||||
rendered.WriteRune(runes[len(runes)-1])
|
||||
rendered.WriteString(underlineEnd)
|
||||
}
|
||||
case textEditorVisual:
|
||||
rendered.WriteString(underlineStart + " " + underlineEnd)
|
||||
default:
|
||||
if lipgloss.Width(line.text) < width {
|
||||
rendered.WriteString(reverseStart + " " + reverseEnd)
|
||||
} else if len(runes) > 0 {
|
||||
// A full visual row has no spare cell. Re-render its last
|
||||
// character as the block cursor without adding layout width.
|
||||
value := rendered.String()
|
||||
rendered.Reset()
|
||||
rendered.WriteString(ansi.Truncate(value, max(0, width-1), ""))
|
||||
rendered.WriteString(reverseStart)
|
||||
rendered.WriteRune(runes[len(runes)-1])
|
||||
rendered.WriteString(reverseEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rendered.String()
|
||||
}
|
||||
507
text_editor_test.go
Normal file
507
text_editor_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
type memoryTextClipboard struct {
|
||||
text string
|
||||
written string
|
||||
readErr error
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func (c *memoryTextClipboard) ReadText() (string, error) {
|
||||
return c.text, c.readErr
|
||||
}
|
||||
|
||||
func (c *memoryTextClipboard) WriteText(value string) error {
|
||||
c.written = value
|
||||
return c.writeErr
|
||||
}
|
||||
|
||||
func TestVimTextEditorWordMotions(t *testing.T) {
|
||||
const value = "one,two THREE four\nlast"
|
||||
editor := newTextEditor(value, true)
|
||||
|
||||
editor.handleKey(runeKey("e"), true)
|
||||
if editor.Cursor != 2 {
|
||||
t.Fatalf("e cursor = %d, want 2", editor.Cursor)
|
||||
}
|
||||
editor.Cursor = 0
|
||||
editor.handleKey(runeKey("E"), true)
|
||||
if editor.Cursor != 6 {
|
||||
t.Fatalf("E cursor = %d, want 6", editor.Cursor)
|
||||
}
|
||||
editor.Cursor = 0
|
||||
editor.handleKey(runeKey("w"), true)
|
||||
if editor.Cursor != 3 {
|
||||
t.Fatalf("w cursor = %d, want punctuation at 3", editor.Cursor)
|
||||
}
|
||||
editor.Cursor = 0
|
||||
editor.handleKey(runeKey("W"), true)
|
||||
if editor.Cursor != 9 {
|
||||
t.Fatalf("W cursor = %d, want 9", editor.Cursor)
|
||||
}
|
||||
editor.Cursor = 18
|
||||
editor.handleKey(runeKey("b"), true)
|
||||
if editor.Cursor != 15 {
|
||||
t.Fatalf("b cursor = %d, want 15", editor.Cursor)
|
||||
}
|
||||
editor.Cursor = 18
|
||||
editor.handleKey(runeKey("B"), true)
|
||||
if editor.Cursor != 15 {
|
||||
t.Fatalf("B cursor = %d, want 15", editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorWordEndMotionsRepeat(t *testing.T) {
|
||||
const value = "one,two THREE four\nlast"
|
||||
editor := newTextEditor(value, true)
|
||||
for index, want := range []int{2, 3, 6, 13, 18, 23} {
|
||||
editor.handleKey(runeKey("e"), true)
|
||||
if editor.Cursor != want {
|
||||
t.Fatalf("e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
|
||||
}
|
||||
}
|
||||
|
||||
editor.Cursor = 0
|
||||
for index, want := range []int{6, 13, 18, 23} {
|
||||
editor.handleKey(runeKey("E"), true)
|
||||
if editor.Cursor != want {
|
||||
t.Fatalf("E repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorWordEndRepeatsAcrossSoftWraps(t *testing.T) {
|
||||
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
|
||||
for index, want := range []int{9, 19, 21} {
|
||||
editor.handleKeyAtWidth(runeKey("e"), true, 10)
|
||||
if editor.Cursor != want {
|
||||
t.Fatalf("soft-wrap e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorFindAndRepeat(t *testing.T) {
|
||||
editor := newTextEditor("foo bar foo", true)
|
||||
editor.handleKey(runeKey("f"), true)
|
||||
editor.handleKey(runeKey("o"), true)
|
||||
if editor.Cursor != 1 {
|
||||
t.Fatalf("fo cursor = %d, want 1", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey(";"), true)
|
||||
if editor.Cursor != 2 {
|
||||
t.Fatalf("first ; cursor = %d, want 2", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey(";"), true)
|
||||
if editor.Cursor != 9 {
|
||||
t.Fatalf("second ; cursor = %d, want 9", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey(","), true)
|
||||
if editor.Cursor != 2 {
|
||||
t.Fatalf(", cursor = %d, want 2", editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorTillRepeatAdvancesPastPreviousTarget(t *testing.T) {
|
||||
editor := newTextEditor("xxa-b-c-d", true)
|
||||
editor.handleKey(runeKey("t"), true)
|
||||
editor.handleKey(runeKey("-"), true)
|
||||
if editor.Cursor != 2 {
|
||||
t.Fatalf("t- cursor = %d, want 2", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey(";"), true)
|
||||
if editor.Cursor != 4 {
|
||||
t.Fatalf("; cursor = %d, want 4", editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorSwitchesModesAndInserts(t *testing.T) {
|
||||
editor := newTextEditor("task", true)
|
||||
if editor.Mode != textEditorNormal {
|
||||
t.Fatalf("initial mode = %s", editor.Mode)
|
||||
}
|
||||
editor.handleKey(runeKey("i"), true)
|
||||
editor.handleKey(runeKey("x"), true)
|
||||
if editor.Text != "xtask" || editor.Mode != textEditorInsert {
|
||||
t.Fatalf("insert result = %q mode=%s", editor.Text, editor.Mode)
|
||||
}
|
||||
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
|
||||
if editor.Mode != textEditorNormal {
|
||||
t.Fatalf("escape mode = %s", editor.Mode)
|
||||
}
|
||||
if editor.Cursor != 0 {
|
||||
t.Fatalf("escape cursor = %d, want 0", editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorSubstituteDeletesCharacterAndEntersInsert(t *testing.T) {
|
||||
editor := newTextEditor("abc", true)
|
||||
editor.Cursor = 1
|
||||
editor.handleKey(runeKey("s"), true)
|
||||
if editor.Text != "ac" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
|
||||
t.Fatalf("substitute result = %#v", editor)
|
||||
}
|
||||
editor.handleKey(runeKey("X"), true)
|
||||
if editor.Text != "aXc" {
|
||||
t.Fatalf("substitute insertion result = %q", editor.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorNormalMotionsStayOnCharactersWithinLine(t *testing.T) {
|
||||
editor := newTextEditor("ab\n cd\n", true)
|
||||
editor.Cursor = 1
|
||||
editor.handleKey(runeKey("l"), true)
|
||||
if editor.Cursor != 1 {
|
||||
t.Fatalf("l crossed line at cursor %d", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey("x"), true)
|
||||
if editor.Text != "a\n cd\n" {
|
||||
t.Fatalf("x result = %q", editor.Text)
|
||||
}
|
||||
editor.handleKey(runeKey("x"), true)
|
||||
if editor.Text != "a\n cd\n" {
|
||||
t.Fatalf("x deleted newline: %q", editor.Text)
|
||||
}
|
||||
|
||||
editor.Cursor = 4
|
||||
editor.handleKey(runeKey("j"), true)
|
||||
if editor.Cursor != 7 {
|
||||
t.Fatalf("j cursor = %d, want empty last line at 7", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey("h"), true)
|
||||
if editor.Cursor != 7 {
|
||||
t.Fatalf("h crossed from empty line at cursor %d", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey("X"), true)
|
||||
if editor.Text != "a\n cd\n" {
|
||||
t.Fatalf("X deleted newline: %q", editor.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimTextEditorDocumentMotionsUseFirstNonBlank(t *testing.T) {
|
||||
editor := newTextEditor(" first\n last", true)
|
||||
editor.Cursor = 10
|
||||
editor.handleKey(runeKey("g"), true)
|
||||
editor.handleKey(runeKey("g"), true)
|
||||
if editor.Cursor != 2 {
|
||||
t.Fatalf("gg cursor = %d, want 2", editor.Cursor)
|
||||
}
|
||||
editor.handleKey(runeKey("G"), true)
|
||||
if editor.Cursor != 11 {
|
||||
t.Fatalf("G cursor = %d, want 11", editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorHighlightsCurrentLineWithoutChangingLayout(t *testing.T) {
|
||||
editor := newTextEditor("short\nsecond", true)
|
||||
lines := renderTextEditor(editor, 20, true)
|
||||
if len(lines) != 2 || strings.Contains(joinEditorLines(lines), "█") {
|
||||
t.Fatalf("cursor changed editor layout: %#v", lines)
|
||||
}
|
||||
if width := ansi.StringWidth(lines[0].text); width != 20 {
|
||||
t.Fatalf("active line width = %d, want 20", width)
|
||||
}
|
||||
if width := ansi.StringWidth(lines[1].text); width != len("second") {
|
||||
t.Fatalf("inactive line width = %d", width)
|
||||
}
|
||||
if !lines[0].active || lines[1].active {
|
||||
t.Fatalf("active rows = %#v", lines)
|
||||
}
|
||||
|
||||
editor.handleKey(runeKey("j"), true)
|
||||
lines = renderTextEditor(editor, 20, true)
|
||||
if width := ansi.StringWidth(lines[0].text); width != len("short") {
|
||||
t.Fatalf("old line remained highlighted at width %d", width)
|
||||
}
|
||||
if width := ansi.StringWidth(lines[1].text); width != 20 {
|
||||
t.Fatalf("new active line width = %d, want 20", width)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) {
|
||||
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
|
||||
editor.Cursor = 16
|
||||
rendered := renderTextEditor(editor, 10, true)
|
||||
if len(rendered) != 3 {
|
||||
t.Fatalf("wrapped rows = %d, want 3", len(rendered))
|
||||
}
|
||||
|
||||
app := App{prEditField: prEditBodyField}
|
||||
app.prEditEditors[prEditBodyField] = editor
|
||||
rows := app.prEditFieldLines("description", prEditBodyField, 14)
|
||||
if len(rows) != 4 {
|
||||
t.Fatalf("field rows = %#v", rows)
|
||||
}
|
||||
want := []string{"│ abcdefghij", "│ klmnopqrst", "│ uv"}
|
||||
for index, expected := range want {
|
||||
plain := strings.TrimRight(ansi.Strip(rows[index+1]), " ")
|
||||
if plain != expected {
|
||||
t.Fatalf("wrapped row %d = %q, want %q", index, plain, expected)
|
||||
}
|
||||
if ansi.StringWidth(rows[index+1]) > 12 {
|
||||
t.Fatalf("wrapped row %d is too wide: %d", index, ansi.StringWidth(rows[index+1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) {
|
||||
const value = "abcdefghijklmnopqrstuv"
|
||||
editor := newTextEditor(value, true)
|
||||
editor.Cursor = 2
|
||||
|
||||
editor.handleKeyAtWidth(runeKey("j"), true, 10)
|
||||
if editor.Cursor != 12 {
|
||||
t.Fatalf("first visual j cursor = %d, want 12", editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("$"), true, 10)
|
||||
if editor.Cursor != 19 {
|
||||
t.Fatalf("visual $ cursor = %d, want 19", editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("l"), true, 10)
|
||||
if editor.Cursor != 19 {
|
||||
t.Fatalf("l crossed soft wrap at cursor %d", editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("j"), true, 10)
|
||||
if editor.Cursor != 21 {
|
||||
t.Fatalf("second visual j cursor = %d, want 21", editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("0"), true, 10)
|
||||
if editor.Cursor != 20 {
|
||||
t.Fatalf("visual 0 cursor = %d, want 20", editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("k"), true, 10)
|
||||
if editor.Cursor != 10 {
|
||||
t.Fatalf("visual k cursor = %d, want 10", editor.Cursor)
|
||||
}
|
||||
if editor.Text != value {
|
||||
t.Fatalf("visual navigation changed stored text: %q", editor.Text)
|
||||
}
|
||||
|
||||
rendered := renderTextEditor(editor, 10, true)
|
||||
activeRows := 0
|
||||
for _, line := range rendered {
|
||||
if line.active {
|
||||
activeRows++
|
||||
}
|
||||
}
|
||||
if activeRows != 1 {
|
||||
t.Fatalf("active visual rows = %d, want 1", activeRows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorDoesNotAddPhantomRowAtExactSoftWrap(t *testing.T) {
|
||||
editor := newTextEditor("abcdefghijklmnopqrst", true)
|
||||
editor.Cursor = len([]rune(editor.Text))
|
||||
rendered := renderTextEditor(editor, 10, true)
|
||||
if len(rendered) != 2 {
|
||||
t.Fatalf("rendered rows = %d, want 2: %#v", len(rendered), rendered)
|
||||
}
|
||||
if !rendered[1].active {
|
||||
t.Fatalf("last wrapped row is not active: %#v", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorLineEndingNormalizationRemovesTerminalCarriageReturns(t *testing.T) {
|
||||
const mixed = "first\nsecond\r\nthird\rfourth"
|
||||
normalized := normalizeLineEndings(mixed)
|
||||
if normalized != "first\nsecond\nthird\nfourth" {
|
||||
t.Fatalf("normalized text = %q", normalized)
|
||||
}
|
||||
editor := newTextEditor(normalized, true)
|
||||
for _, line := range renderTextEditor(editor, 80, true) {
|
||||
if strings.ContainsRune(line.text, '\r') {
|
||||
t.Fatalf("rendered terminal carriage return in %#v", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
|
||||
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
|
||||
editor.Cursor = 2
|
||||
editor.handleKeyAtWidth(runeKey("v"), true, 10)
|
||||
editor.handleKeyAtWidth(runeKey("j"), true, 10)
|
||||
editor.handleKeyAtWidth(runeKey("l"), true, 10)
|
||||
if editor.Mode != textEditorVisual || editor.Cursor != 13 {
|
||||
t.Fatalf("visual selection mode=%s cursor=%d", editor.Mode, editor.Cursor)
|
||||
}
|
||||
editor.handleKeyAtWidth(runeKey("d"), true, 10)
|
||||
if editor.Text != "abopqrstuv" {
|
||||
t.Fatalf("visual delete result = %q", editor.Text)
|
||||
}
|
||||
if editor.Mode != textEditorNormal || editor.Cursor != 2 {
|
||||
t.Fatalf("after visual delete mode=%s cursor=%d", editor.Mode, editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
|
||||
clipboard := &memoryTextClipboard{}
|
||||
editor := newTextEditor("abcdef", true)
|
||||
editor.clipboard = clipboard
|
||||
editor.handleKey(runeKey("v"), true)
|
||||
editor.handleKey(runeKey("l"), true)
|
||||
editor.handleKey(runeKey("l"), true)
|
||||
editor.handleKey(runeKey("y"), true)
|
||||
if clipboard.written != "abc" {
|
||||
t.Fatalf("yanked text = %q, want abc", clipboard.written)
|
||||
}
|
||||
if editor.Text != "abcdef" || editor.Mode != textEditorNormal {
|
||||
t.Fatalf("yank changed editor: %#v", editor)
|
||||
}
|
||||
|
||||
clipboard.text = "XY"
|
||||
editor.Cursor = 0
|
||||
editor.handleKey(runeKey("p"), true)
|
||||
if editor.Text != "aXYbcdef" || editor.Cursor != 2 {
|
||||
t.Fatalf("paste result text=%q cursor=%d", editor.Text, editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimVisualLineYankCollapsesSoftWraps(t *testing.T) {
|
||||
clipboard := &memoryTextClipboard{}
|
||||
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
|
||||
editor.clipboard = clipboard
|
||||
editor.Cursor = 2
|
||||
editor.handleKeyAtWidth(runeKey("V"), true, 10)
|
||||
editor.handleKeyAtWidth(runeKey("j"), true, 10)
|
||||
editor.handleKeyAtWidth(runeKey("y"), true, 10)
|
||||
if clipboard.written != "abcdefghijklmnopqrst" {
|
||||
t.Fatalf("linewise soft-wrap yank = %q", clipboard.written)
|
||||
}
|
||||
if strings.ContainsRune(clipboard.written, '\n') {
|
||||
t.Fatalf("soft-wrap yank introduced newline: %q", clipboard.written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimVisualFindAcceptsArbitraryTarget(t *testing.T) {
|
||||
editor := newTextEditor("one x two", true)
|
||||
editor.handleKey(runeKey("v"), true)
|
||||
editor.handleKey(runeKey("f"), true)
|
||||
editor.handleKey(runeKey("x"), true)
|
||||
if editor.Mode != textEditorVisual || editor.Cursor != 4 {
|
||||
t.Fatalf("visual fx mode=%s cursor=%d", editor.Mode, editor.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimClipboardErrorsRemainVisibleAndPreserveSelection(t *testing.T) {
|
||||
clipboard := &memoryTextClipboard{writeErr: errors.New("clipboard failed")}
|
||||
editor := newTextEditor("abc", true)
|
||||
editor.clipboard = clipboard
|
||||
editor.handleKey(runeKey("v"), true)
|
||||
editor.handleKey(runeKey("y"), true)
|
||||
if editor.err == nil || !strings.Contains(editor.err.Error(), "clipboard failed") {
|
||||
t.Fatalf("clipboard error = %v", editor.err)
|
||||
}
|
||||
if editor.Mode != textEditorVisual || editor.Text != "abc" {
|
||||
t.Fatalf("failed yank changed selection: %#v", editor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorRendersModeSpecificCursorsAndVisualSelection(t *testing.T) {
|
||||
editor := newTextEditor("abc", true)
|
||||
normal := renderTextEditor(editor, 10, true)
|
||||
if !strings.Contains(normal[0].text, "\x1b[7m") || ansi.Strip(normal[0].text) != "abc " {
|
||||
t.Fatalf("normal cursor rendering = %q", normal[0].text)
|
||||
}
|
||||
|
||||
editor.handleKey(runeKey("i"), true)
|
||||
insert := renderTextEditor(editor, 10, true)
|
||||
if !strings.Contains(insert[0].text, "\x1b[4m") {
|
||||
t.Fatalf("insert cursor rendering = %q", insert[0].text)
|
||||
}
|
||||
if width := ansi.StringWidth(insert[0].text); width != 10 {
|
||||
t.Fatalf("insert cursor changed row width to %d", width)
|
||||
}
|
||||
if plain := strings.TrimRight(ansi.Strip(insert[0].text), " "); plain != "abc" {
|
||||
t.Fatalf("insert cursor hid or shifted text: %q", plain)
|
||||
}
|
||||
|
||||
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
|
||||
editor.handleKey(runeKey("v"), true)
|
||||
editor.handleKey(runeKey("l"), true)
|
||||
visual := renderTextEditor(editor, 10, true)
|
||||
if editor.modeLabel() != "VISUAL" || !strings.Contains(visual[0].text, "\x1b[7m") {
|
||||
t.Fatalf("visual rendering mode=%s text=%q", editor.modeLabel(), visual[0].text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardwareInsertCursorDoesNotAlterRenderedText(t *testing.T) {
|
||||
editor := newTextEditor("abc", true)
|
||||
editor.hardwareCursor = true
|
||||
editor.handleKey(runeKey("i"), true)
|
||||
rendered := renderTextEditor(editor, 10, true)
|
||||
if plain := strings.TrimRight(ansi.Strip(rendered[0].text), " "); plain != "abc" {
|
||||
t.Fatalf("hardware cursor altered text: %q", plain)
|
||||
}
|
||||
if strings.Contains(rendered[0].text, "\x1b[4m") {
|
||||
t.Fatalf("hardware cursor retained fallback underline: %q", rendered[0].text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHighlightingPreservesTextWidthsAndCursorIndexes(t *testing.T) {
|
||||
const markdown = "# Heading\nUse `code` and [link](https://example.com)."
|
||||
editor := newTextEditor(markdown, true)
|
||||
editor.highlightMarkdown = true
|
||||
rendered := renderTextEditor(editor, 80, false)
|
||||
var lines []string
|
||||
for _, line := range rendered {
|
||||
lines = append(lines, line.text)
|
||||
}
|
||||
highlighted := strings.Join(lines, "\n")
|
||||
if ansi.Strip(highlighted) != markdown {
|
||||
t.Fatalf("highlighting changed text:\n%q\nwant:\n%q", ansi.Strip(highlighted), markdown)
|
||||
}
|
||||
if !strings.Contains(highlighted, "\x1b[") {
|
||||
t.Fatalf("Markdown was not highlighted: %q", highlighted)
|
||||
}
|
||||
for index, line := range rendered {
|
||||
if ansi.StringWidth(line.text) != ansi.StringWidth(ansi.Strip(line.text)) {
|
||||
t.Fatalf("highlighted line %d changed width", index)
|
||||
}
|
||||
}
|
||||
|
||||
editor.Cursor = strings.Index(markdown, "code")
|
||||
editor.handleKey(runeKey("s"), true)
|
||||
editor.handleKey(runeKey("C"), true)
|
||||
if editor.Text != strings.Replace(markdown, "code", "Code", 1) {
|
||||
t.Fatalf("highlighted edit changed wrong rune: %q", editor.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHighlightTokenKinds(t *testing.T) {
|
||||
const markdown = "# Heading\nText **strong** and *emphasis* with `code` and [link](target)\n<!-- comment -->\n"
|
||||
styles := editorMarkdownStyles(markdown)
|
||||
assertStyleAt := func(fragment string, want editorMarkdownStyle) {
|
||||
t.Helper()
|
||||
index := len([]rune(markdown[:strings.Index(markdown, fragment)]))
|
||||
if styles[index] != want {
|
||||
t.Fatalf("style for %q = %d, want %d", fragment, styles[index], want)
|
||||
}
|
||||
}
|
||||
assertStyleAt("# Heading", editorMarkdownHeading)
|
||||
assertStyleAt("**strong**", editorMarkdownStrong)
|
||||
assertStyleAt("*emphasis*", editorMarkdownEmphasis)
|
||||
assertStyleAt("`code`", editorMarkdownCode)
|
||||
assertStyleAt("link", editorMarkdownLink)
|
||||
assertStyleAt("target", editorMarkdownDestination)
|
||||
assertStyleAt("<!-- comment -->", editorMarkdownComment)
|
||||
}
|
||||
|
||||
func joinEditorLines(lines []editorRenderedLine) string {
|
||||
var values []string
|
||||
for _, line := range lines {
|
||||
values = append(values, line.text)
|
||||
}
|
||||
return strings.Join(values, "")
|
||||
}
|
||||
|
||||
func runeKey(value string) tea.KeyMsg {
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
|
||||
}
|
||||
13
theme.go
13
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
|
||||
}
|
||||
|
||||
108
tui.go
108
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
|
||||
@@ -100,6 +115,14 @@ type App struct {
|
||||
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"},
|
||||
@@ -1365,6 +1452,9 @@ var (
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
361
tui_test.go
361
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) {
|
||||
|
||||
15
types.go
15
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
|
||||
|
||||
Reference in New Issue
Block a user