From 3daf7773112c831b8a907e04f613ad58369ebf07 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 19 Jun 2026 20:22:06 -0700 Subject: [PATCH] feat: add PR review comments panel in sidebar Add a new "Reviews" sidebar panel that displays PR review comments when a PR is loaded. Includes fetching inline/general comments from GitHub, displaying them grouped by file, and posting new general comments via an input widget at the bottom of the panel. Co-Authored-By: Claude Opus 4.6 --- internal/app/app.go | 1 + internal/app/callbacks.go | 31 ++ internal/app/commands_git.go | 6 + internal/app/eventloop.go | 18 ++ internal/app/pr.go | 41 +++ internal/app/widgets.go | 53 ++-- internal/github/github.go | 120 ++++++++ internal/github/github_test.go | 116 +++++++- internal/ui/reviews_widget.go | 437 +++++++++++++++++++++++++++++ internal/ui/reviews_widget_test.go | 235 ++++++++++++++++ tests/e2e/sidebar_test.go | 8 +- 11 files changed, 1036 insertions(+), 30 deletions(-) create mode 100644 internal/ui/reviews_widget.go create mode 100644 internal/ui/reviews_widget_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 1b898e2d..5237ada8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -41,6 +41,7 @@ type App struct { Explorer *ui.ExplorerWidget Search *ui.SearchWidget Changes *ui.ChangesWidget + Reviews *ui.ReviewsWidget MenuBar *ui.MenuBarWidget StatusBar *ui.StatusBarWidget Status *view.StatusBar diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index adc96283..d12a9f98 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -51,6 +51,10 @@ func (a *App) ShowSidebarMoreMenu(sx, sy int) { ui.MenuSep(), {Label: "Help", Command: "changes.help"}, } + case "reviews": + items = []ui.ContextMenuItem{ + {Label: "Refresh", Command: "reviews.refresh"}, + } } if len(items) > 0 { openContextMenu(a, items, sx, sy) @@ -513,6 +517,33 @@ func registerWidgetCallbacks(app *App) { app.Changes.OnCommit = app.CommitChanges app.Changes.OnConfirmDiscard = app.ConfirmDiscard + app.Reviews.OnOpenFile = func(path string, line int) { + app.EditorGroup.OpenFile(path) + if line > 0 { + app.EditorGroup.GoToLine(line) + } + app.Root.SetFocus(app.EditorGroup) + } + app.Reviews.OnAddComment = func(body string) { + owner := app.Reviews.Owner + repo := app.Reviews.Repo + number := app.Reviews.Number + if owner == "" || number == 0 { + app.StatusWarn("No PR loaded for comments") + return + } + go func() { + err := github.AddPRComment(owner, repo, number, body) + app.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentAddResult{ + Owner: owner, + Repo: repo, + Number: number, + Err: err, + })) + }() + app.StatusNotify("Posting comment...") + } + app.ContentSplit.OnResize = func(height int) { if height <= 0 { app.ContentSplit.ShowBottom = false diff --git a/internal/app/commands_git.go b/internal/app/commands_git.go index 2f553391..45730e0c 100644 --- a/internal/app/commands_git.go +++ b/internal/app/commands_git.go @@ -216,6 +216,12 @@ func registerGitCommands(app *App) { Handler: func() { app.Changes.Refresh() }, }) + reg.Register(command.Command{ + ID: "reviews.refresh", Title: "PR: Refresh Comments", + Keywords: []string{"pr", "reviews", "comments", "reload"}, + Handler: func() { app.RefreshPRComments() }, + }) + reg.Register(command.Command{ ID: "changes.stage", Title: "Git: Stage File", Keywords: []string{"git", "changes", "add"}, diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index 7c33ef76..1ec97b2e 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -296,6 +296,24 @@ func RunEventLoop( app.Root.SetFocus(app.Changes) app.Sidebar.SetPanelDirty("changes", app.Changes.TotalChanges() > 0) app.StatusNotify(fmt.Sprintf("Opened PR #%d: %s (%d files)", v.Info.Number, v.Info.Title, len(v.Info.Files))) + // Also fetch comments for the PR. + app.Reviews.SetPR(v.Info.Owner, v.Info.Repo, v.Info.Number) + app.FetchPRComments(v.Info.Owner, v.Info.Repo, v.Info.Number) + } + case *PRCommentsFetchResult: + app.Reviews.Loading = false + if v.Err != nil { + app.StatusError("Failed to fetch PR comments: " + v.Err.Error()) + } else { + app.Reviews.SetComments(v.Comments) + app.Sidebar.SetPanelDirty("reviews", len(v.Comments) > 0) + } + case *PRCommentAddResult: + if v.Err != nil { + app.StatusError("Failed to post comment: " + v.Err.Error()) + } else { + app.StatusNotify("Comment posted") + app.RefreshPRComments() } } redraw() diff --git a/internal/app/pr.go b/internal/app/pr.go index a226bece..306a7d26 100644 --- a/internal/app/pr.go +++ b/internal/app/pr.go @@ -22,6 +22,47 @@ type DiffContentResult struct { Err error } +// PRCommentsFetchResult delivers fetched PR comments to the event loop. +type PRCommentsFetchResult struct { + Owner string + Repo string + Number int + Comments []github.PRComment + Err error +} + +// PRCommentAddResult delivers the result of posting a new comment. +type PRCommentAddResult struct { + Owner string + Repo string + Number int + Err error +} + +// FetchPRComments fetches comments for a PR asynchronously and posts the +// result back to the event loop. +func (a *App) FetchPRComments(owner, repo string, number int) { + a.Reviews.Loading = true + go func() { + comments, err := github.FetchPRComments(owner, repo, number) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentsFetchResult{ + Owner: owner, + Repo: repo, + Number: number, + Comments: comments, + Err: err, + })) + }() +} + +// RefreshPRComments re-fetches comments for the currently loaded PR. +func (a *App) RefreshPRComments() { + if a.Reviews.Owner == "" || a.Reviews.Number == 0 { + return + } + a.FetchPRComments(a.Reviews.Owner, a.Reviews.Repo, a.Reviews.Number) +} + func (a *App) FetchAndOpenPR(url string) { owner, repo, number, err := github.ParsePRURL(url) if err != nil { diff --git a/internal/app/widgets.go b/internal/app/widgets.go index f72b4346..61ad3277 100644 --- a/internal/app/widgets.go +++ b/internal/app/widgets.go @@ -1,15 +1,15 @@ package app import ( - "os" - "path/filepath" - "strings" "github.com/eugenioenko/ttt/internal/config" "github.com/eugenioenko/ttt/internal/github" "github.com/eugenioenko/ttt/internal/term" "github.com/eugenioenko/ttt/internal/ui" "github.com/eugenioenko/ttt/internal/view" "github.com/eugenioenko/ttt/internal/workspace" + "os" + "path/filepath" + "strings" ) func isPRURL(arg string) bool { @@ -150,11 +150,13 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work search.SetWorkDirs(ws.Paths()) search.Debounce.DelayMs = cfg.Settings.Search.Debounce changes := ui.NewChangesWidget(ws.Paths()...) + reviews := ui.NewReviewsWidget() sidebar := ui.NewSidebarWidget() sidebar.AddPanel("explorer", "Explore", explorer) sidebar.AddPanel("search", "Find", search) sidebar.AddPanel("changes", "Changes", changes) + sidebar.AddPanel("reviews", "Reviews", reviews) hasFolders := len(ws.Paths()) > 0 sidebar.Visible = hasFolders sidebar.Borders = borders @@ -177,27 +179,28 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work root.SetFocus(editorGroup) return &App{ - Root: root, - EditorGroup: editorGroup, - Sidebar: sidebar, - SplitPanel: splitPanel, - ContentSplit: contentSplit, - BottomPanel: bottomPanel, - Explorer: explorer, - Search: search, - Changes: changes, - MenuBar: menuBar, - StatusBar: statusBar, - Status: status, - Borders: borders, - Settings: &cfg.Settings, - Workspace: ws, - Palette: BuildTerminalPalettePtr(cfg.Theme), - TerminalPanel: terminalPanel, - Problems: problems, - References: references, - DocVersions: make(map[string]int), - AllDiagnostics: make(map[string][]ui.Diagnostic), - LspNotified: make(map[string]bool), + Root: root, + EditorGroup: editorGroup, + Sidebar: sidebar, + SplitPanel: splitPanel, + ContentSplit: contentSplit, + BottomPanel: bottomPanel, + Explorer: explorer, + Search: search, + Changes: changes, + Reviews: reviews, + MenuBar: menuBar, + StatusBar: statusBar, + Status: status, + Borders: borders, + Settings: &cfg.Settings, + Workspace: ws, + Palette: BuildTerminalPalettePtr(cfg.Theme), + TerminalPanel: terminalPanel, + Problems: problems, + References: references, + DocVersions: make(map[string]int), + AllDiagnostics: make(map[string][]ui.Diagnostic), + LspNotified: make(map[string]bool), } } diff --git a/internal/github/github.go b/internal/github/github.go index f258ae89..e58c04b4 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -133,6 +133,126 @@ func FetchFileContent(owner, repo, path, ref string) (string, error) { return string(out), nil } +// PRComment represents a single comment on a pull request. +type PRComment struct { + ID int + Body string + User string + CreatedAt string + Path string + Line int + IsInline bool +} + +// FetchPRComments fetches both inline review comments and general issue comments +// for the given pull request. +func FetchPRComments(owner, repo string, number int) ([]PRComment, error) { + var comments []PRComment + + // Fetch inline/review comments + endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number) + cmd := exec.Command("gh", "api", endpoint, "--paginate") + out, err := cmd.Output() + if err == nil && len(out) > 0 { + var reviewComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` + Line *int `json:"line"` + } + if err := json.Unmarshal(out, &reviewComments); err == nil { + for _, rc := range reviewComments { + line := 0 + if rc.Line != nil { + line = *rc.Line + } + comments = append(comments, PRComment{ + ID: rc.ID, + Body: rc.Body, + User: rc.User.Login, + CreatedAt: rc.CreatedAt, + Path: rc.Path, + Line: line, + IsInline: true, + }) + } + } + } + + // Fetch general issue comments + endpoint = fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number) + cmd = exec.Command("gh", "api", endpoint, "--paginate") + out, err = cmd.Output() + if err == nil && len(out) > 0 { + var issueComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(out, &issueComments); err == nil { + for _, ic := range issueComments { + comments = append(comments, PRComment{ + ID: ic.ID, + Body: ic.Body, + User: ic.User.Login, + CreatedAt: ic.CreatedAt, + IsInline: false, + }) + } + } + } + + return comments, nil +} + +// AddPRComment adds a general comment to a pull request. +func AddPRComment(owner, repo string, number int, body string) error { + endpoint := fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number) + cmd := exec.Command("gh", "api", endpoint, "-f", "body="+body, "--method", "POST") + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("gh api comment failed: %w", err) + } + return nil +} + +// AddPRInlineComment adds an inline review comment to a pull request. +func AddPRInlineComment(owner, repo string, number int, body, path string, line int) error { + headSHA, err := fetchPRHeadSHA(owner, repo, number) + if err != nil { + return fmt.Errorf("fetch head SHA: %w", err) + } + endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number) + cmd := exec.Command("gh", "api", endpoint, + "-f", "body="+body, + "-f", "path="+path, + "-F", fmt.Sprintf("line=%d", line), + "-f", "commit_id="+headSHA, + "--method", "POST") + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("gh api inline comment failed: %w", err) + } + return nil +} + +func fetchPRHeadSHA(owner, repo string, number int) (string, error) { + query := fmt.Sprintf(`query { repository(owner: %q, name: %q) { pullRequest(number: %d) { headRefOid } } }`, + owner, repo, number) + cmd := exec.Command("gh", "api", "graphql", "-f", "query="+query, + "--jq", ".data.repository.pullRequest.headRefOid") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("gh graphql failed: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + func SplitMultiFileDiff(unified string) map[string]string { result := make(map[string]string) lines := strings.Split(unified, "\n") diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 0d88bb30..c571d64c 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -1,6 +1,9 @@ package github -import "testing" +import ( + "encoding/json" + "testing" +) func TestParsePRURL(t *testing.T) { tests := []struct { @@ -65,3 +68,114 @@ diff --git a/pkg/file2.go b/pkg/file2.go t.Error("missing pkg/file2.go") } } + +func TestPRCommentJSONParsing(t *testing.T) { + // Test parsing inline review comments JSON. + reviewJSON := `[ + { + "id": 101, + "body": "Looks good", + "user": {"login": "alice"}, + "created_at": "2025-01-15T10:30:00Z", + "path": "main.go", + "line": 42 + }, + { + "id": 102, + "body": "Needs a test", + "user": {"login": "bob"}, + "created_at": "2025-01-16T08:00:00Z", + "path": "utils.go", + "line": null + } + ]` + + var reviewComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` + Line *int `json:"line"` + } + if err := json.Unmarshal([]byte(reviewJSON), &reviewComments); err != nil { + t.Fatalf("failed to parse review comments JSON: %v", err) + } + if len(reviewComments) != 2 { + t.Fatalf("expected 2 review comments, got %d", len(reviewComments)) + } + + // First comment has a line number. + if reviewComments[0].ID != 101 { + t.Errorf("expected ID 101, got %d", reviewComments[0].ID) + } + if reviewComments[0].User.Login != "alice" { + t.Errorf("expected user alice, got %q", reviewComments[0].User.Login) + } + if reviewComments[0].Line == nil || *reviewComments[0].Line != 42 { + t.Error("expected line 42 for first comment") + } + + // Second comment has null line. + if reviewComments[1].Line != nil { + t.Error("expected nil line for second comment") + } + + // Test parsing issue comments JSON. + issueJSON := `[ + { + "id": 201, + "body": "General feedback", + "user": {"login": "charlie"}, + "created_at": "2025-01-17T12:00:00Z" + } + ]` + + var issueComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal([]byte(issueJSON), &issueComments); err != nil { + t.Fatalf("failed to parse issue comments JSON: %v", err) + } + if len(issueComments) != 1 { + t.Fatalf("expected 1 issue comment, got %d", len(issueComments)) + } + if issueComments[0].User.Login != "charlie" { + t.Errorf("expected user charlie, got %q", issueComments[0].User.Login) + } +} + +func TestPRCommentType(t *testing.T) { + // Test that the PRComment struct stores data correctly. + c := PRComment{ + ID: 1, + Body: "Test comment", + User: "testuser", + CreatedAt: "2025-06-18T10:00:00Z", + Path: "src/main.go", + Line: 15, + IsInline: true, + } + if c.ID != 1 { + t.Errorf("expected ID 1, got %d", c.ID) + } + if c.User != "testuser" { + t.Errorf("expected user testuser, got %q", c.User) + } + if !c.IsInline { + t.Error("expected IsInline to be true") + } + if c.Path != "src/main.go" { + t.Errorf("expected path src/main.go, got %q", c.Path) + } + if c.Line != 15 { + t.Errorf("expected line 15, got %d", c.Line) + } +} diff --git a/internal/ui/reviews_widget.go b/internal/ui/reviews_widget.go new file mode 100644 index 00000000..6552b31a --- /dev/null +++ b/internal/ui/reviews_widget.go @@ -0,0 +1,437 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/term" + "github.com/gdamore/tcell/v2" +) + +// reviewItemKind distinguishes different row types in the flattened list. +type reviewItemKind int + +const ( + reviewItemComment reviewItemKind = iota + reviewItemHeader + reviewItemSpacer +) + +// reviewItem is a single row in the flat list rendered by ReviewsWidget. +type reviewItem struct { + kind reviewItemKind + commentIndex int // index into ReviewsWidget.Comments + headerLabel string // for header rows +} + +// ReviewsWidget displays PR review comments in the sidebar. +type ReviewsWidget struct { + BaseWidget + SelectableList + + Comments []github.PRComment + items []reviewItem + + // PR coordinates, needed for adding comments. + Owner string + Repo string + Number int + + Loading bool + + // Input for adding a general comment. + Input *InputWidget + inputFocused bool + + // Callbacks set by the app layer. + OnOpenFile func(path string, line int) + OnAddComment func(body string) + OnAddInlineComment func(body, path string, line int) + + scrollbar Scrollbar +} + +// NewReviewsWidget returns a ready-to-use ReviewsWidget. +func NewReviewsWidget() *ReviewsWidget { + inp := NewInputWidget() + inp.Placeholder = "Add a comment..." + return &ReviewsWidget{Input: inp} +} + +func (r *ReviewsWidget) Focusable() bool { return true } + +// SetComments replaces the current comments and rebuilds the flat list. +func (r *ReviewsWidget) SetComments(comments []github.PRComment) { + r.Comments = comments + r.buildItems() + r.ClampSelected(len(r.items)) +} + +// SetPR stores the PR coordinates used for posting new comments. +func (r *ReviewsWidget) SetPR(owner, repo string, number int) { + r.Owner = owner + r.Repo = repo + r.Number = number +} + +// buildItems flattens comments into a renderable list. +// Inline comments are grouped by file path; general comments appear under +// a separate "General" header. +func (r *ReviewsWidget) buildItems() { + r.items = nil + + // Count general comments to decide whether to add a "General" header. + hasGeneral := false + for _, c := range r.Comments { + if !c.IsInline { + hasGeneral = true + break + } + } + + // Group inline comments by file path, preserving order of first + // appearance. + type fileGroup struct { + path string + comments []int // indices into r.Comments + } + var groups []fileGroup + groupIdx := map[string]int{} + for i, c := range r.Comments { + if !c.IsInline { + continue + } + gi, ok := groupIdx[c.Path] + if !ok { + gi = len(groups) + groupIdx[c.Path] = gi + groups = append(groups, fileGroup{path: c.Path}) + } + groups[gi].comments = append(groups[gi].comments, i) + } + + // Render inline groups. + for _, fg := range groups { + r.items = append(r.items, reviewItem{kind: reviewItemHeader, headerLabel: fg.path}) + for _, ci := range fg.comments { + r.items = append(r.items, reviewItem{kind: reviewItemComment, commentIndex: ci}) + } + r.items = append(r.items, reviewItem{kind: reviewItemSpacer}) + } + + // Render general comments. + if hasGeneral { + r.items = append(r.items, reviewItem{kind: reviewItemHeader, headerLabel: "General"}) + for i := range r.Comments { + if !r.Comments[i].IsInline { + r.items = append(r.items, reviewItem{kind: reviewItemComment, commentIndex: i}) + } + } + r.items = append(r.items, reviewItem{kind: reviewItemSpacer}) + } +} + +// formatTimestamp returns a short display string for an ISO 8601 timestamp. +func formatTimestamp(ts string) string { + if len(ts) >= 10 { + return ts[:10] + } + return ts +} + +// truncate returns s shortened to at most maxLen runes, adding "..." if +// truncated. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + if maxLen <= 3 { + return string(runes[:maxLen]) + } + return string(runes[:maxLen-3]) + "..." +} + +func (r *ReviewsWidget) Render(surface *RenderSurface) { + w, h := surface.Size() + surface.Fill(term.Cell{Ch: ' '}) + + if len(r.Comments) == 0 { + msg := "No comments" + if r.Loading { + msg = "Loading..." + } + for i, ch := range msg { + if i+1 < w { + surface.SetCell(i+1, 0, term.Cell{Ch: ch, Style: term.StyleMuted}) + } + } + return + } + + if h <= 0 { + return + } + + // Reserve 2 rows at the bottom for the input area. + listH := h - 2 + if listH < 1 { + listH = h + } + + r.EnsureVisible(listH) + + rect := r.GetRect() + r.scrollbar.X = rect.X + w - 1 + r.scrollbar.Y = rect.Y + r.scrollbar.Height = listH + r.scrollbar.TotalItems = len(r.items) + r.scrollbar.TopItem = r.ScrollTop + + contentW := w + if r.scrollbar.Visible() { + contentW = w - 1 + } + + for y := 0; y < listH; y++ { + idx := r.ScrollTop + y + if idx >= len(r.items) { + break + } + item := r.items[idx] + + style := term.StyleDefault + if idx == r.Selected && !r.inputFocused { + style = term.StyleSidebarSelected + } + + // Clear the row. + for x := 0; x < contentW; x++ { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + } + + switch item.kind { + case reviewItemHeader: + r.renderHeader(surface, y, contentW, style, item.headerLabel) + case reviewItemComment: + r.renderComment(surface, y, contentW, style, idx == r.Selected && !r.inputFocused, item.commentIndex) + case reviewItemSpacer: + // already cleared + } + } + + r.scrollbar.Render(surface, w-1, 0) + + // Draw input area at the bottom. + if listH < h { + // Separator line. + for x := 0; x < w; x++ { + surface.SetCell(x, listH, term.Cell{Ch: '─', Style: term.StyleBorder}) + } + r.Input.Render(surface, 0, listH+1, w) + } +} + +func (r *ReviewsWidget) renderHeader(surface *RenderSurface, y, w int, style term.Style, label string) { + x := 0 + headerStyle := term.StyleMuted + if style == term.StyleSidebarSelected { + headerStyle = style + } + + // File path or "General" label + for _, ch := range label { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: headerStyle}) + x++ + } +} + +func (r *ReviewsWidget) renderComment(surface *RenderSurface, y, w int, style term.Style, selected bool, ci int) { + if ci < 0 || ci >= len(r.Comments) { + return + } + c := r.Comments[ci] + + x := 1 + + // Author + author := "@" + c.User + authorStyle := style + for _, ch := range author { + if x >= w-12 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: authorStyle}) + x++ + } + x++ + + // For inline comments show line number. + if c.IsInline && c.Line > 0 { + loc := fmt.Sprintf("L%d", c.Line) + locStyle := term.StyleMuted + if selected { + locStyle = style + } + for _, ch := range loc { + if x >= w-12 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: locStyle}) + x++ + } + x++ + } + + // Timestamp (right-aligned) + ts := formatTimestamp(c.CreatedAt) + tsRunes := []rune(ts) + if len(tsRunes) > 0 { + tsX := w - len(tsRunes) - 1 + if tsX > x { + tsStyle := term.StyleMuted + if selected { + tsStyle = style + } + for i, ch := range tsRunes { + surface.SetCell(tsX+i, y, term.Cell{Ch: ch, Style: tsStyle}) + } + } + } + + // Body (first line, truncated to fill space between metadata and timestamp) + body := strings.ReplaceAll(c.Body, "\n", " ") + body = strings.ReplaceAll(body, "\r", "") + maxBodyW := w - x - len(tsRunes) - 2 + if maxBodyW < 4 { + // Not enough room; skip the body preview altogether since the + // timestamp already fills the row. + return + } + body = truncate(body, maxBodyW) + bodyStyle := term.StyleMuted + if selected { + bodyStyle = style + } + for _, ch := range body { + if x >= w-len(tsRunes)-2 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: bodyStyle}) + x++ + } +} + +// CursorPosition implements CursorProvider so the input caret blinks. +func (r *ReviewsWidget) CursorPosition() (int, int, bool) { + if !r.inputFocused { + return 0, 0, false + } + rect := r.GetRect() + h := rect.H + inputY := rect.Y + h - 1 + return r.Input.CursorX(rect.X), inputY, true +} + +// FocusedInput implements InputHolder so copy/paste routes to the input. +func (r *ReviewsWidget) FocusedInput() *InputWidget { + if r.inputFocused { + return r.Input + } + return nil +} + +func (r *ReviewsWidget) HandleEvent(ev tcell.Event) EventResult { + // When the input is focused, route keys there. + if r.inputFocused { + if tev, ok := ev.(*tcell.EventKey); ok { + switch tev.Key() { + case tcell.KeyEscape: + r.inputFocused = false + return EventConsumed + case tcell.KeyEnter: + body := strings.TrimSpace(r.Input.Text) + if body != "" && r.OnAddComment != nil { + r.OnAddComment(body) + r.Input.Clear() + } + r.inputFocused = false + return EventConsumed + default: + r.Input.HandleEvent(ev) + return EventConsumed + } + } + } + + // Scrollbar + if newTop, consumed := r.scrollbar.HandleEvent(ev); consumed { + r.ScrollTop = newTop + if r.scrollbar.IsDragging() { + return EventCaptured + } + return EventConsumed + } + + rect := r.GetRect() + h := rect.H + listH := h - 2 + if listH < 1 { + listH = h + } + + // Mouse click on the input row. + if tev, ok := ev.(*tcell.EventMouse); ok { + if tev.Buttons()&tcell.Button1 != 0 { + _, my := tev.Position() + row := my - rect.Y + if row >= listH { + r.inputFocused = true + r.Input.HandleClick(tev.Position()) + return EventConsumed + } + } + } + + // SelectableList handles up/down/enter/wheel/click for the list area. + listRect := Rect{X: rect.X, Y: rect.Y, W: rect.W, H: listH} + res := r.SelectableList.HandleListEvent(ev, listRect, len(r.items)) + if res.Result == EventConsumed { + if res.Action == ListActionActivate { + r.activateSelected() + } + return EventConsumed + } + + // 'i' to focus the input. + if tev, ok := ev.(*tcell.EventKey); ok { + if tev.Key() == tcell.KeyRune && tev.Rune() == 'i' { + r.inputFocused = true + return EventConsumed + } + } + + return EventIgnored +} + +func (r *ReviewsWidget) activateSelected() { + if r.Selected < 0 || r.Selected >= len(r.items) { + return + } + item := r.items[r.Selected] + if item.kind != reviewItemComment { + return + } + ci := item.commentIndex + if ci < 0 || ci >= len(r.Comments) { + return + } + c := r.Comments[ci] + if c.IsInline && r.OnOpenFile != nil { + r.OnOpenFile(c.Path, c.Line) + } +} diff --git a/internal/ui/reviews_widget_test.go b/internal/ui/reviews_widget_test.go new file mode 100644 index 00000000..406e370a --- /dev/null +++ b/internal/ui/reviews_widget_test.go @@ -0,0 +1,235 @@ +package ui + +import ( + "testing" + + "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/term" +) + +func TestNewReviewsWidget(t *testing.T) { + w := NewReviewsWidget() + if w == nil { + t.Fatal("NewReviewsWidget returned nil") + } + if !w.Focusable() { + t.Error("ReviewsWidget should be focusable") + } + if w.Input == nil { + t.Error("ReviewsWidget.Input should not be nil") + } +} + +func TestReviewsWidgetSetComments(t *testing.T) { + w := NewReviewsWidget() + comments := []github.PRComment{ + {ID: 1, Body: "Inline comment", User: "alice", Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Body: "General comment", User: "bob", IsInline: false}, + {ID: 3, Body: "Another inline", User: "alice", Path: "main.go", Line: 20, IsInline: true}, + {ID: 4, Body: "Other file", User: "charlie", Path: "utils.go", Line: 5, IsInline: true}, + } + w.SetComments(comments) + + if len(w.Comments) != 4 { + t.Fatalf("expected 4 comments, got %d", len(w.Comments)) + } + + // Verify items were built: should have headers, comments, and spacers. + // Expected structure: + // header: "main.go" + // comment: inline #1 + // comment: inline #3 + // spacer + // header: "utils.go" + // comment: inline #4 + // spacer + // header: "General" + // comment: general #2 + // spacer + if len(w.items) != 10 { + t.Fatalf("expected 10 items, got %d", len(w.items)) + } + + // First item should be a header for "main.go". + if w.items[0].kind != reviewItemHeader { + t.Errorf("item 0: expected header, got %d", w.items[0].kind) + } + + // Items 1 and 2 should be comments. + if w.items[1].kind != reviewItemComment { + t.Errorf("item 1: expected comment, got %d", w.items[1].kind) + } + if w.items[2].kind != reviewItemComment { + t.Errorf("item 2: expected comment, got %d", w.items[2].kind) + } + + // Item 3 should be a spacer. + if w.items[3].kind != reviewItemSpacer { + t.Errorf("item 3: expected spacer, got %d", w.items[3].kind) + } +} + +func TestReviewsWidgetEmptyRender(t *testing.T) { + w := NewReviewsWidget() + w.SetRect(Rect{X: 0, Y: 0, W: 40, H: 10}) + + grid := makeGrid(40, 10) + surface := NewRenderSurface(grid, Rect{X: 0, Y: 0, W: 40, H: 10}) + w.Render(surface) + + // Should render "No comments" in the first row. + var text []rune + for x := 0; x < 40; x++ { + c := grid[0][x] + if c.Ch != '.' && c.Ch != ' ' { + text = append(text, c.Ch) + } + } + got := string(text) + if got != "Nocomments" { + t.Errorf("expected 'Nocomments' text (without spaces), got %q", got) + } +} + +func TestReviewsWidgetRenderWithComments(t *testing.T) { + w := NewReviewsWidget() + w.SetRect(Rect{X: 0, Y: 0, W: 60, H: 15}) + w.SetComments([]github.PRComment{ + {ID: 1, Body: "Great work!", User: "alice", CreatedAt: "2025-01-15T10:30:00Z", Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Body: "Needs docs", User: "bob", CreatedAt: "2025-01-16T08:00:00Z", IsInline: false}, + }) + + grid := makeGrid(60, 15) + surface := NewRenderSurface(grid, Rect{X: 0, Y: 0, W: 60, H: 15}) + w.Render(surface) + + // The first row should contain the file header "main.go". + // Extract text by checking style (header uses StyleMuted, default grid is StyleDefault with '.') + var row0 []rune + for x := 0; x < 60; x++ { + c := grid[0][x] + if c.Style != term.StyleDefault || c.Ch != '.' { + if c.Ch != ' ' { + row0 = append(row0, c.Ch) + } + } + } + headerText := string(row0) + if headerText != "main.go" { + t.Errorf("expected file header 'main.go', got %q", headerText) + } +} + +func TestReviewsWidgetSetPR(t *testing.T) { + w := NewReviewsWidget() + w.SetPR("owner", "repo", 42) + if w.Owner != "owner" { + t.Errorf("expected owner 'owner', got %q", w.Owner) + } + if w.Repo != "repo" { + t.Errorf("expected repo 'repo', got %q", w.Repo) + } + if w.Number != 42 { + t.Errorf("expected number 42, got %d", w.Number) + } +} + +func TestReviewsWidgetSelectedStyle(t *testing.T) { + w := NewReviewsWidget() + w.SetRect(Rect{X: 0, Y: 0, W: 60, H: 15}) + w.SetComments([]github.PRComment{ + {ID: 1, Body: "Comment 1", User: "alice", CreatedAt: "2025-01-15", Path: "file.go", Line: 1, IsInline: true}, + {ID: 2, Body: "Comment 2", User: "bob", CreatedAt: "2025-01-16", Path: "file.go", Line: 2, IsInline: true}, + }) + + // Select the second item (first comment). + w.Selected = 1 + + grid := makeGrid(60, 15) + surface := NewRenderSurface(grid, Rect{X: 0, Y: 0, W: 60, H: 15}) + w.Render(surface) + + // The selected row (y=1) should use StyleSidebarSelected. + found := false + for x := 0; x < 60; x++ { + c := grid[1][x] + if c.Style == term.StyleSidebarSelected { + found = true + break + } + } + if !found { + t.Error("expected selected row to use StyleSidebarSelected") + } +} + +func TestReviewsWidgetOnOpenFileCallback(t *testing.T) { + w := NewReviewsWidget() + w.SetComments([]github.PRComment{ + {ID: 1, Body: "Review", User: "alice", Path: "main.go", Line: 42, IsInline: true}, + }) + + var openedPath string + var openedLine int + w.OnOpenFile = func(path string, line int) { + openedPath = path + openedLine = line + } + + // Select the comment (item index 1, after the header at 0). + w.Selected = 1 + w.activateSelected() + + if openedPath != "main.go" { + t.Errorf("expected path 'main.go', got %q", openedPath) + } + if openedLine != 42 { + t.Errorf("expected line 42, got %d", openedLine) + } +} + +func TestReviewsWidgetGeneralCommentNoCallback(t *testing.T) { + w := NewReviewsWidget() + w.SetComments([]github.PRComment{ + {ID: 1, Body: "General feedback", User: "bob", IsInline: false}, + }) + + called := false + w.OnOpenFile = func(path string, line int) { + called = true + } + + // Select the general comment (item index 1, after "General" header at 0). + w.Selected = 1 + w.activateSelected() + + if called { + t.Error("OnOpenFile should not be called for general comments") + } +} + +func TestReviewsWidgetLoadingState(t *testing.T) { + w := NewReviewsWidget() + w.Loading = true + w.SetRect(Rect{X: 0, Y: 0, W: 40, H: 10}) + + grid := makeGrid(40, 10) + surface := NewRenderSurface(grid, Rect{X: 0, Y: 0, W: 40, H: 10}) + w.Render(surface) + + // Should render "Loading..." when Loading=true and no comments. + // Extract styled text (non-default-styled cells). + var text []rune + for x := 0; x < 40; x++ { + c := grid[0][x] + if c.Style != term.StyleDefault || c.Ch != '.' { + if c.Ch != ' ' { + text = append(text, c.Ch) + } + } + } + got := string(text) + if got != "Loading..." { + t.Errorf("expected 'Loading...' text, got %q", got) + } +} diff --git a/tests/e2e/sidebar_test.go b/tests/e2e/sidebar_test.go index d6766c5a..5b7ced12 100644 --- a/tests/e2e/sidebar_test.go +++ b/tests/e2e/sidebar_test.go @@ -123,19 +123,19 @@ func TestSidebarTabOverflow(t *testing.T) { t.Error("expected at least one hidden tab due to overflow") } - h.app.SplitPanel.DividerPos = 30 + h.app.SplitPanel.DividerPos = 40 h.app.Root.SetSize(80, 24) h.redraw() row = h.screenRow(sidebarY) - t.Logf("sidebar row (w=30): %q", row) + t.Logf("sidebar row (w=40): %q", row) if strings.Contains(row, "»") { - t.Errorf("expected no overflow with default sidebar, got: %s", row) + t.Errorf("expected no overflow with wide sidebar, got: %s", row) } if len(h.app.Sidebar.TabBar.HiddenTabs) != 0 { - t.Errorf("expected 0 hidden tabs with default sidebar, got %d", len(h.app.Sidebar.TabBar.HiddenTabs)) + t.Errorf("expected 0 hidden tabs with wide sidebar, got %d", len(h.app.Sidebar.TabBar.HiddenTabs)) } }