From 82a7d7719a95346bd3505cdc7b5fee9fea948f64 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 19 Jun 2026 20:22:22 -0700 Subject: [PATCH] feat: add PR review comments panel with chat thread UI Add a sliding right panel that displays PR review comments in an email/chat thread aesthetic. The panel fetches both inline review comments and general issue comments via the GitHub CLI, displays them chronologically with author attribution, timestamps, and word-wrapped body text. Inline comments show clickable file:line references. Includes a compose area at the bottom for submitting new comments. New command: "Git: Show PR Comments" (pr.showComments) - opens comments for the currently loaded PR or prompts for a PR URL. Co-Authored-By: Claude Opus 4.6 --- internal/app/app.go | 1 + internal/app/commands_git.go | 5 + internal/app/eventloop.go | 34 ++ internal/app/pr.go | 144 ++++++ internal/github/github.go | 111 ++++ internal/github/github_test.go | 61 ++- internal/ui/comment_panel_widget.go | 616 +++++++++++++++++++++++ internal/ui/comment_panel_widget_test.go | 462 +++++++++++++++++ 8 files changed, 1433 insertions(+), 1 deletion(-) create mode 100644 internal/ui/comment_panel_widget.go create mode 100644 internal/ui/comment_panel_widget_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 1b898e2d..3710041c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -68,6 +68,7 @@ type App struct { AllDiagnostics map[string][]ui.Diagnostic Keybindings []config.KeyBinding LspNotified map[string]bool + CommentPanel *ui.CommentPanelWidget Reg *command.Registry Running *bool quitPending bool diff --git a/internal/app/commands_git.go b/internal/app/commands_git.go index 2f553391..74348daa 100644 --- a/internal/app/commands_git.go +++ b/internal/app/commands_git.go @@ -341,4 +341,9 @@ func registerPRCommands(app *App) { Keywords: []string{"git", "pull request", "github"}, Handler: func() { app.Changes.RemovePRGroups() }, }) + reg.Register(command.Command{ + ID: "pr.showComments", Title: "Git: Show PR Comments", + Keywords: []string{"git", "pull request", "github", "comments", "review"}, + Handler: app.ShowPRCommentsDialog, + }) } diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index 7c33ef76..aa30cecd 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -275,6 +275,40 @@ func RunEventLoop( dv.FinishLoading() } } + case *PRCommentsResult: + if v.Err != nil { + app.StatusError("Failed to fetch PR comments: " + v.Err.Error()) + if app.CommentPanel != nil { + app.CommentPanel.Loading = false + } + } else { + if app.CommentPanel != nil { + var items []ui.CommentItem + for _, c := range v.Comments { + items = append(items, ui.CommentItem{ + ID: c.ID, + Author: c.User, + Timestamp: c.CreatedAt, + Body: c.Body, + FilePath: c.Path, + Line: c.Line, + IsInline: c.IsInline, + InReplyTo: c.InReplyTo, + }) + } + app.CommentPanel.SetComments(items) + count := len(items) + app.StatusNotify(fmt.Sprintf("Loaded %d comment(s)", count)) + } + } + case *PRCommentSubmitResult: + if v.Err != nil { + app.StatusError("Failed to submit comment: " + v.Err.Error()) + } else { + app.StatusNotify("Comment submitted") + // Refresh comments + app.FetchPRComments(v.Owner, v.Repo, v.Number, v.Title) + } case *PrFetchResult: app.Changes.Loading = false if v.Err != nil { diff --git a/internal/app/pr.go b/internal/app/pr.go index a226bece..aa5072e7 100644 --- a/internal/app/pr.go +++ b/internal/app/pr.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/ui" "github.com/gdamore/tcell/v2" ) @@ -22,6 +23,25 @@ type DiffContentResult struct { Err error } +// PRCommentsResult carries async comment fetch results back to the event loop. +type PRCommentsResult struct { + Owner string + Repo string + Number int + Title string + Comments []github.PRComment + Err error +} + +// PRCommentSubmitResult carries async comment submission results. +type PRCommentSubmitResult struct { + Owner string + Repo string + Number int + Title string + Err error +} + func (a *App) FetchAndOpenPR(url string) { owner, repo, number, err := github.ParsePRURL(url) if err != nil { @@ -49,3 +69,127 @@ func (a *App) FetchAndOpenPR(url string) { a.Screen.PostEvent(tcell.NewEventInterrupt(&PrFetchResult{URL: url, Info: info, Diffs: diffs})) }() } + +// FetchPRComments fetches PR comments asynchronously and shows them in the panel. +func (a *App) FetchPRComments(owner, repo string, number int, title string) { + // If a panel is already open, reuse it (just refresh comments) + if a.CommentPanel != nil { + a.CommentPanel.Loading = true + } else { + // Create and show the panel immediately with loading state + panel := ui.NewCommentPanelWidget(fmt.Sprintf("PR #%d: %s", number, title)) + panel.Borders = a.Borders + panel.Loading = true + panel.OnClose = func() { + a.DismissCommentPanel() + } + panel.OnOpenFile = func(path string, line int) { + a.EditorGroup.OpenFile(path) + if line > 0 { + a.EditorGroup.GoToLine(line) + } + a.Root.SetFocus(a.EditorGroup) + } + panel.OnSubmit = func(body string) { + a.submitPRComment(owner, repo, number, title, body) + } + a.CommentPanel = panel + a.Root.PushOverlay(ui.Overlay{Widget: panel, Modal: false}) + a.Root.SetFocus(panel) + } + + // Fetch comments in background + go func() { + comments, err := github.FetchPRComments(owner, repo, number) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentsResult{ + Owner: owner, + Repo: repo, + Number: number, + Title: title, + Comments: comments, + Err: err, + })) + }() +} + +// submitPRComment sends a comment to a PR and refreshes the thread. +func (a *App) submitPRComment(owner, repo string, number int, title, body string) { + a.StatusNotify("Submitting comment...") + go func() { + err := github.AddPRComment(owner, repo, number, body) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentSubmitResult{ + Owner: owner, + Repo: repo, + Number: number, + Title: title, + Err: err, + })) + }() +} + +// DismissCommentPanel removes the comment panel overlay. +func (a *App) DismissCommentPanel() { + if a.CommentPanel != nil { + a.Root.PopOverlay() + a.CommentPanel = nil + a.FocusEditor() + } +} + +// ShowPRCommentsForGroup opens the comment panel for a PR group. +func (a *App) ShowPRCommentsForGroup() { + // Find the first PR group in changes + for _, g := range a.Changes.Groups { + if g.IsPR { + owner := g.PROwner + repo := g.PRRepo + // Parse number from group name + var number int + fmt.Sscanf(g.Name, "PR #%d:", &number) + if number == 0 { + continue + } + title := g.Name + a.FetchPRComments(owner, repo, number, title) + return + } + } + a.StatusWarn("No PR open. Use 'Git: Review PR' first.") +} + +// ShowPRCommentsDialog prompts for a PR URL and opens comments. +func (a *App) ShowPRCommentsDialog() { + if a.Root.HasOverlay() { + return + } + if !github.IsGHInstalled() { + a.StatusError("GitHub CLI (gh) is required. Install from https://cli.github.com/") + return + } + // If there's already a PR open, show its comments directly + for _, g := range a.Changes.Groups { + if g.IsPR { + a.ShowPRCommentsForGroup() + return + } + } + // Otherwise prompt for URL + dialog := ui.NewInputDialogWidget("PR Comments", "https://github.com/owner/repo/pull/123", "") + dialog.ConfirmLabel = "Show Comments" + dialog.Borders = a.Borders + dialog.OnSubmit = func(url string) { + a.DismissDialog() + if url != "" { + owner, repo, number, err := github.ParsePRURL(url) + if err != nil { + a.StatusError("Invalid PR URL: " + err.Error()) + return + } + a.FetchPRComments(owner, repo, number, fmt.Sprintf("PR #%d", number)) + } + } + dialog.OnDismiss = func() { + a.DismissDialog() + } + a.ShowDialog(dialog) +} diff --git a/internal/github/github.go b/internal/github/github.go index f258ae89..04432626 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -133,6 +133,117 @@ 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 // non-empty for inline/review comments + Line int // line number for inline comments + IsInline bool + InReplyTo int // ID of parent comment for threaded replies +} + +// FetchPRComments fetches both review (inline) comments and general issue +// comments for a pull request and returns them merged chronologically. +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", + "--jq", `[.[] | {id: .id, body: .body, user: .user.login, created_at: .created_at, path: .path, line: (.line // .original_line // 0), in_reply_to_id: (.in_reply_to_id // 0)}]`) + out, err := cmd.Output() + if err == nil && len(out) > 0 { + var items []struct { + ID int `json:"id"` + Body string `json:"body"` + User string `json:"user"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` + Line int `json:"line"` + InReplyToID int `json:"in_reply_to_id"` + } + if err := json.Unmarshal(out, &items); err == nil { + for _, item := range items { + comments = append(comments, PRComment{ + ID: item.ID, + Body: item.Body, + User: item.User, + CreatedAt: item.CreatedAt, + Path: item.Path, + Line: item.Line, + IsInline: true, + InReplyTo: item.InReplyToID, + }) + } + } + } + + // Fetch general issue comments + endpoint = fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number) + cmd = exec.Command("gh", "api", endpoint, "--paginate", + "--jq", `[.[] | {id: .id, body: .body, user: .user.login, created_at: .created_at}]`) + out, err = cmd.Output() + if err == nil && len(out) > 0 { + var items []struct { + ID int `json:"id"` + Body string `json:"body"` + User string `json:"user"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(out, &items); err == nil { + for _, item := range items { + comments = append(comments, PRComment{ + ID: item.ID, + Body: item.Body, + User: item.User, + CreatedAt: item.CreatedAt, + IsInline: false, + }) + } + } + } + + // Sort by creation time + sortCommentsByTime(comments) + return comments, nil +} + +func sortCommentsByTime(comments []PRComment) { + for i := 1; i < len(comments); i++ { + for j := i; j > 0 && comments[j].CreatedAt < comments[j-1].CreatedAt; j-- { + comments[j], comments[j-1] = comments[j-1], comments[j] + } + } +} + +// 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, "-X", "POST", "-f", "body="+body) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("add comment failed: %s", string(out)) + } + return nil +} + +// AddPRReviewComment adds an inline review comment to a pull request. +func AddPRReviewComment(owner, repo string, number int, body, path string, line int, commitSHA string) error { + endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number) + cmd := exec.Command("gh", "api", endpoint, "-X", "POST", + "-f", "body="+body, + "-f", "path="+path, + "-F", fmt.Sprintf("line=%d", line), + "-f", "commit_id="+commitSHA, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("add review comment failed: %s", string(out)) + } + return 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..cbb207e4 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -1,6 +1,8 @@ package github -import "testing" +import ( + "testing" +) func TestParsePRURL(t *testing.T) { tests := []struct { @@ -65,3 +67,60 @@ diff --git a/pkg/file2.go b/pkg/file2.go t.Error("missing pkg/file2.go") } } + +func TestSortCommentsByTime(t *testing.T) { + comments := []PRComment{ + {ID: 3, CreatedAt: "2024-03-15T10:00:00Z"}, + {ID: 1, CreatedAt: "2024-01-10T10:00:00Z"}, + {ID: 2, CreatedAt: "2024-02-20T10:00:00Z"}, + } + sortCommentsByTime(comments) + if comments[0].ID != 1 || comments[1].ID != 2 || comments[2].ID != 3 { + t.Errorf("comments not sorted by time: got IDs %d, %d, %d", + comments[0].ID, comments[1].ID, comments[2].ID) + } +} + +func TestSortCommentsByTimeAlreadySorted(t *testing.T) { + comments := []PRComment{ + {ID: 1, CreatedAt: "2024-01-01T00:00:00Z"}, + {ID: 2, CreatedAt: "2024-02-01T00:00:00Z"}, + {ID: 3, CreatedAt: "2024-03-01T00:00:00Z"}, + } + sortCommentsByTime(comments) + if comments[0].ID != 1 || comments[1].ID != 2 || comments[2].ID != 3 { + t.Errorf("already sorted comments reordered: got IDs %d, %d, %d", + comments[0].ID, comments[1].ID, comments[2].ID) + } +} + +func TestSortCommentsByTimeEmpty(t *testing.T) { + var comments []PRComment + sortCommentsByTime(comments) // should not panic +} + +func TestPRCommentTypes(t *testing.T) { + // Verify PRComment struct fields work correctly + c := PRComment{ + ID: 42, + Body: "looks good", + User: "reviewer", + CreatedAt: "2024-06-15T12:00:00Z", + Path: "main.go", + Line: 10, + IsInline: true, + InReplyTo: 0, + } + if c.ID != 42 { + t.Error("unexpected ID") + } + if c.User != "reviewer" { + t.Error("unexpected User") + } + if !c.IsInline { + t.Error("expected inline comment") + } + if c.Path != "main.go" { + t.Error("unexpected Path") + } +} diff --git a/internal/ui/comment_panel_widget.go b/internal/ui/comment_panel_widget.go new file mode 100644 index 00000000..c5af33be --- /dev/null +++ b/internal/ui/comment_panel_widget.go @@ -0,0 +1,616 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/eugenioenko/ttt/internal/term" + + "github.com/gdamore/tcell/v2" +) + +// CommentItem represents a single comment displayed in the panel. +type CommentItem struct { + ID int + Author string + Timestamp string + Body string + FilePath string // non-empty for inline comments + Line int // line number for inline comments + IsInline bool + InReplyTo int +} + +// CommentPanelWidget displays PR review comments in a sliding right panel +// with an email/chat thread aesthetic. +type CommentPanelWidget struct { + BaseWidget + + Title string // e.g. "PR #42: Fix bug" + Comments []CommentItem + Borders *term.BorderSet + Loading bool + + // Callbacks + OnClose func() + OnSubmit func(body string) + OnOpenFile func(path string, line int) + + // Scroll state + scrollTop int + totalLines int // total visual lines in rendered thread + + // Compose area + Input *InputWidget + composing bool + + // Close button hit region + closeHit HitRegion + + // File reference hit regions + fileHits []fileHitRegion + + // Scrollbar + scrollbar Scrollbar +} + +type fileHitRegion struct { + HitRegion + Path string + Line int +} + +// NewCommentPanelWidget creates a new comment panel. +func NewCommentPanelWidget(title string) *CommentPanelWidget { + inp := NewInputWidget() + inp.Prefix = " " + inp.Placeholder = "Write a comment..." + return &CommentPanelWidget{ + Title: title, + Input: inp, + } +} + +func (c *CommentPanelWidget) Focusable() bool { return true } + +// SetComments replaces the comment list and resets scroll. +func (c *CommentPanelWidget) SetComments(items []CommentItem) { + c.Comments = items + c.scrollTop = 0 + c.Loading = false +} + +// panelWidth calculates the panel width (~40% of screen, min 30, max 80). +func (c *CommentPanelWidget) panelWidth(screenW int) int { + w := screenW * 40 / 100 + if w < 30 { + w = 30 + } + if w > 80 { + w = 80 + } + if w > screenW-10 { + w = screenW - 10 + } + return w +} + +// wrapText breaks text into lines that fit within the given width. +func wrapText(text string, width int) []string { + if width <= 0 { + return nil + } + var result []string + for _, paragraph := range strings.Split(text, "\n") { + if paragraph == "" { + result = append(result, "") + continue + } + runes := []rune(paragraph) + for len(runes) > 0 { + if len(runes) <= width { + result = append(result, string(runes)) + break + } + // Find break point + breakAt := width + for breakAt > 0 && runes[breakAt] != ' ' { + breakAt-- + } + if breakAt == 0 { + breakAt = width // force break + } + result = append(result, string(runes[:breakAt])) + runes = runes[breakAt:] + // Skip leading space on next line + if len(runes) > 0 && runes[0] == ' ' { + runes = runes[1:] + } + } + } + return result +} + +// formatTimestamp converts an ISO 8601 timestamp to a short relative time. +func formatTimestamp(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + // Try without timezone + t, err = time.Parse("2006-01-02T15:04:05Z", ts) + if err != nil { + return ts + } + } + now := time.Now() + diff := now.Sub(t) + + switch { + case diff < time.Minute: + return "just now" + case diff < time.Hour: + m := int(diff.Minutes()) + if m == 1 { + return "1 min ago" + } + return fmt.Sprintf("%d mins ago", m) + case diff < 24*time.Hour: + h := int(diff.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + case diff < 30*24*time.Hour: + d := int(diff.Hours() / 24) + if d == 1 { + return "yesterday" + } + return fmt.Sprintf("%d days ago", d) + default: + return t.Format("Jan 2, 2006") + } +} + +// Render draws the comment panel as a right-side overlay. +func (c *CommentPanelWidget) Render(surface *RenderSurface) { + sw, sh := surface.Size() + panelW := c.panelWidth(sw) + panelX := sw - panelW + panelH := sh + + b := term.SingleBorderSet() + if c.Borders != nil { + b = *c.Borders + } + + // Clear panel area + surface.ClearRect(panelX, 0, panelW, panelH, term.StyleDefault) + + // Draw left border + for y := 0; y < panelH; y++ { + surface.SetCell(panelX, y, term.Cell{Ch: b.Vertical, Style: term.StyleBorder}) + } + + contentX := panelX + 1 + contentW := panelW - 2 // 1 for left border, 1 for scrollbar/right padding + + // --- Header row --- + headerY := 0 + // Draw header background + for x := contentX; x < panelX+panelW; x++ { + surface.SetCell(x, headerY, term.Cell{Ch: ' ', Style: term.StyleStatusBar}) + } + + // Title (bold) + titleRunes := []rune(c.Title) + maxTitleW := contentW - 4 // leave room for close button + tx := contentX + 1 + for i, ch := range titleRunes { + if i >= maxTitleW { + break + } + surface.SetCell(tx+i, headerY, term.Cell{Ch: ch, Style: term.StyleStatusBar}) + } + + // Close button [X] + closeX := panelX + panelW - 4 + surface.SetCell(closeX, headerY, term.Cell{Ch: '[', Style: term.StyleStatusBar}) + surface.SetCell(closeX+1, headerY, term.Cell{Ch: 'X', Style: term.StyleDanger}) + surface.SetCell(closeX+2, headerY, term.Cell{Ch: ']', Style: term.StyleStatusBar}) + ox, oy := surface.Origin() + c.closeHit = HitRegion{X: ox + closeX, Y: oy + headerY, W: 3} + + // Header divider + headerDivY := 1 + for x := contentX; x < panelX+panelW; x++ { + surface.SetCell(x, headerDivY, term.Cell{Ch: b.Horizontal, Style: term.StyleBorder}) + } + // Tees at border intersections + surface.SetCell(panelX, headerDivY, term.Cell{Ch: b.LeftTee, Style: term.StyleBorder}) + + // --- Compose area (bottom) --- + composeH := 3 // divider + input + hint + composeDivY := panelH - composeH + composeInputY := composeDivY + 1 + composeHintY := composeDivY + 2 + + // Compose divider + for x := contentX; x < panelX+panelW; x++ { + surface.SetCell(x, composeDivY, term.Cell{Ch: b.Horizontal, Style: term.StyleBorder}) + } + surface.SetCell(panelX, composeDivY, term.Cell{Ch: b.LeftTee, Style: term.StyleBorder}) + + // Render input + inputSurface := surface.Sub(Rect{X: contentX, Y: composeInputY, W: contentW, H: 1}) + c.Input.Render(inputSurface, 0, 0, contentW) + + // Hint text + hint := " Enter to submit" + for i, ch := range hint { + if contentX+i >= panelX+panelW-1 { + break + } + surface.SetCell(contentX+i, composeHintY, term.Cell{Ch: ch, Style: term.StyleMuted}) + } + + // --- Thread area --- + threadY := 2 + threadH := composeDivY - threadY + if threadH <= 0 { + return + } + + // Compute rendered lines + c.fileHits = nil + rendered := c.renderComments(contentW - 1) // -1 for scrollbar space + c.totalLines = len(rendered) + + // Clamp scroll + maxScroll := c.totalLines - threadH + if maxScroll < 0 { + maxScroll = 0 + } + if c.scrollTop > maxScroll { + c.scrollTop = maxScroll + } + if c.scrollTop < 0 { + c.scrollTop = 0 + } + + // Draw visible lines + for dy := 0; dy < threadH; dy++ { + lineIdx := c.scrollTop + dy + if lineIdx >= len(rendered) { + break + } + rl := rendered[lineIdx] + x := contentX + for _, cell := range rl.cells { + if x >= panelX+panelW-1 { + break + } + surface.SetCell(x, threadY+dy, cell) + x++ + } + + // Track file hit regions + if rl.filePath != "" { + c.fileHits = append(c.fileHits, fileHitRegion{ + HitRegion: HitRegion{ + X: ox + contentX, + Y: oy + threadY + dy, + W: contentW, + }, + Path: rl.filePath, + Line: rl.fileLine, + }) + } + } + + // Loading indicator + if c.Loading { + msg := "Loading comments..." + msgX := contentX + (contentW-len(msg))/2 + msgY := threadY + threadH/2 + for i, ch := range msg { + surface.SetCell(msgX+i, msgY, term.Cell{Ch: ch, Style: term.StyleMuted}) + } + } else if len(c.Comments) == 0 && !c.Loading { + msg := "No comments yet" + msgX := contentX + (contentW-len(msg))/2 + msgY := threadY + threadH/2 + for i, ch := range msg { + surface.SetCell(msgX+i, msgY, term.Cell{Ch: ch, Style: term.StyleMuted}) + } + } + + // Scrollbar + scrollX := panelX + panelW - 1 + c.scrollbar = Scrollbar{ + X: ox + scrollX, + Y: oy + threadY, + Height: threadH, + TotalItems: c.totalLines, + TopItem: c.scrollTop, + } + c.scrollbar.Render(surface, scrollX, threadY) +} + +// renderedLine represents one visual line in the comment thread. +type renderedLine struct { + cells []term.Cell + filePath string // non-empty if this line is a clickable file reference + fileLine int +} + +// renderComments builds the full list of visual lines for all comments. +func (c *CommentPanelWidget) renderComments(width int) []renderedLine { + if width <= 0 { + return nil + } + var lines []renderedLine + + for i, comment := range c.Comments { + if i > 0 { + // Separator line + sep := make([]term.Cell, width) + for j := range sep { + sep[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + b := term.SingleBorderSet() + if c.Borders != nil { + b = *c.Borders + } + for j := 0; j < width; j++ { + sep[j] = term.Cell{Ch: b.Horizontal, Style: term.StyleBorder} + } + lines = append(lines, renderedLine{cells: sep}) + // Blank line after separator + blank := make([]term.Cell, width) + for j := range blank { + blank[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + lines = append(lines, renderedLine{cells: blank}) + } + + // Author line: " @author timestamp" + authorLine := make([]term.Cell, width) + for j := range authorLine { + authorLine[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + x := 1 + // Author icon + authorStr := "@" + comment.Author + for _, ch := range authorStr { + if x >= width { + break + } + authorLine[x] = term.Cell{ + Ch: ch, Style: term.StyleHoverBold, + } + x++ + } + + // Timestamp (right-aligned or after spacing) + ts := formatTimestamp(comment.Timestamp) + tsRunes := []rune(ts) + tsStart := width - len(tsRunes) - 1 + if tsStart < x+2 { + tsStart = x + 2 + } + for j, ch := range tsRunes { + pos := tsStart + j + if pos >= width { + break + } + authorLine[pos] = term.Cell{Ch: ch, Style: term.StyleMuted} + } + lines = append(lines, renderedLine{cells: authorLine}) + + // File reference line for inline comments + if comment.IsInline && comment.FilePath != "" { + refLine := make([]term.Cell, width) + for j := range refLine { + refLine[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + ref := fmt.Sprintf(" %s:%d", comment.FilePath, comment.Line) + x = 1 + for _, ch := range ref { + if x >= width { + break + } + refLine[x] = term.Cell{Ch: ch, Style: term.StyleSyntaxString} + x++ + } + lines = append(lines, renderedLine{ + cells: refLine, + filePath: comment.FilePath, + fileLine: comment.Line, + }) + } + + // Blank line before body + blankBeforeBody := make([]term.Cell, width) + for j := range blankBeforeBody { + blankBeforeBody[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + lines = append(lines, renderedLine{cells: blankBeforeBody}) + + // Body text with word wrapping + bodyWidth := width - 3 // indentation + if bodyWidth < 10 { + bodyWidth = 10 + } + wrapped := wrapText(comment.Body, bodyWidth) + for _, wl := range wrapped { + bodyLine := make([]term.Cell, width) + for j := range bodyLine { + bodyLine[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + x = 2 // indent body + for _, ch := range wl { + if x >= width { + break + } + bodyLine[x] = term.Cell{Ch: ch, Style: term.StyleDefault} + x++ + } + lines = append(lines, renderedLine{cells: bodyLine}) + } + + // Trailing blank line + blankAfterBody := make([]term.Cell, width) + for j := range blankAfterBody { + blankAfterBody[j] = term.Cell{Ch: ' ', Style: term.StyleDefault} + } + lines = append(lines, renderedLine{cells: blankAfterBody}) + } + + return lines +} + +// HandleEvent handles keyboard and mouse events for the comment panel. +func (c *CommentPanelWidget) HandleEvent(ev tcell.Event) EventResult { + switch tev := ev.(type) { + case *tcell.EventKey: + // If composing, route to input + if c.composing { + switch tev.Key() { + case tcell.KeyEscape: + c.composing = false + return EventConsumed + case tcell.KeyEnter: + text := strings.TrimSpace(c.Input.Text) + if text != "" && c.OnSubmit != nil { + c.OnSubmit(text) + c.Input.Clear() + } + c.composing = false + return EventConsumed + default: + return c.Input.HandleEvent(ev) + } + } + + switch tev.Key() { + case tcell.KeyEscape: + if c.OnClose != nil { + c.OnClose() + } + return EventConsumed + case tcell.KeyUp: + if c.scrollTop > 0 { + c.scrollTop-- + } + return EventConsumed + case tcell.KeyDown: + c.scrollTop++ + return EventConsumed + case tcell.KeyPgUp: + c.scrollTop -= 10 + if c.scrollTop < 0 { + c.scrollTop = 0 + } + return EventConsumed + case tcell.KeyPgDn: + c.scrollTop += 10 + return EventConsumed + case tcell.KeyRune: + if tev.Rune() == 'i' || tev.Rune() == 'c' { + c.composing = true + return EventConsumed + } + if tev.Rune() == 'q' { + if c.OnClose != nil { + c.OnClose() + } + return EventConsumed + } + } + + case *tcell.EventMouse: + mx, my := tev.Position() + btn := tev.Buttons() + + // Close button + if btn&tcell.Button1 != 0 && c.closeHit.Contains(mx, my) { + if c.OnClose != nil { + c.OnClose() + } + return EventConsumed + } + + // File reference clicks + if btn&tcell.Button1 != 0 { + for _, fh := range c.fileHits { + if fh.HitRegion.Contains(mx, my) { + if c.OnOpenFile != nil { + c.OnOpenFile(fh.Path, fh.Line) + } + return EventConsumed + } + } + } + + // Input click (compose area) + if btn&tcell.Button1 != 0 { + if c.Input.HandleClick(mx, my) { + c.composing = true + return EventConsumed + } + } + + // Scrollbar + if newTop, consumed := c.scrollbar.HandleEvent(ev); consumed { + c.scrollTop = newTop + return EventConsumed + } + + // Scroll wheel + if btn&tcell.WheelUp != 0 { + c.scrollTop -= 3 + if c.scrollTop < 0 { + c.scrollTop = 0 + } + return EventConsumed + } + if btn&tcell.WheelDown != 0 { + c.scrollTop += 3 + return EventConsumed + } + + // Consume clicks within panel bounds + rect := c.GetRect() + sw := rect.W + panelW := c.panelWidth(sw) + panelX := sw - panelW + rect.X + if mx >= panelX { + return EventConsumed + } + } + return EventIgnored +} + +// CursorPosition returns the cursor position when composing a comment. +func (c *CommentPanelWidget) CursorPosition() (x, y int, visible bool) { + if !c.composing { + return 0, 0, false + } + rect := c.GetRect() + sw := rect.W + panelW := c.panelWidth(sw) + panelX := sw - panelW + rect.X + contentX := panelX + 1 + + composeInputY := rect.Y + rect.H - 2 + cx := c.Input.CursorX(contentX) + return cx, composeInputY, true +} + +// FocusedInput returns the InputWidget when composing. +func (c *CommentPanelWidget) FocusedInput() *InputWidget { + if c.composing { + return c.Input + } + return nil +} diff --git a/internal/ui/comment_panel_widget_test.go b/internal/ui/comment_panel_widget_test.go new file mode 100644 index 00000000..7e840847 --- /dev/null +++ b/internal/ui/comment_panel_widget_test.go @@ -0,0 +1,462 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/eugenioenko/ttt/internal/term" + + "github.com/gdamore/tcell/v2" +) + +func TestNewCommentPanelWidget(t *testing.T) { + panel := NewCommentPanelWidget("PR #42: Fix bug") + if panel.Title != "PR #42: Fix bug" { + t.Errorf("expected title 'PR #42: Fix bug', got %q", panel.Title) + } + if panel.Input == nil { + t.Fatal("expected Input to be initialized") + } + if !panel.Focusable() { + t.Error("expected panel to be focusable") + } +} + +func TestCommentPanelSetComments(t *testing.T) { + panel := NewCommentPanelWidget("Test PR") + panel.Loading = true + items := []CommentItem{ + {ID: 1, Author: "user1", Timestamp: "2024-01-15T10:00:00Z", Body: "LGTM"}, + {ID: 2, Author: "user2", Timestamp: "2024-01-15T11:00:00Z", Body: "Needs fix"}, + } + panel.SetComments(items) + if panel.Loading { + t.Error("expected Loading to be false after SetComments") + } + if len(panel.Comments) != 2 { + t.Errorf("expected 2 comments, got %d", len(panel.Comments)) + } + if panel.scrollTop != 0 { + t.Error("expected scrollTop to reset to 0") + } +} + +func TestCommentPanelRender(t *testing.T) { + panel := NewCommentPanelWidget("PR #1: Test") + panel.SetComments([]CommentItem{ + {ID: 1, Author: "alice", Timestamp: "2024-06-15T10:00:00Z", Body: "Hello world"}, + }) + + w, h := 80, 24 + panel.SetRect(Rect{X: 0, Y: 0, W: w, H: h}) + cells := make([][]term.Cell, h) + for y := range cells { + cells[y] = make([]term.Cell, w) + } + surface := NewRenderSurface(cells, Rect{X: 0, Y: 0, W: w, H: h}) + panel.Render(surface) + + // Verify panel renders on the right side + panelW := panel.panelWidth(w) + panelX := w - panelW + + // Left border should be a vertical line + borderCell := cells[0][panelX] + bs := term.SingleBorderSet() + if borderCell.Ch != bs.Vertical { + t.Errorf("expected left border at x=%d, got %q", panelX, string(borderCell.Ch)) + } + + // Close button should exist in header + closeX := panelX + panelW - 4 + if closeX >= 0 && closeX < w { + if cells[0][closeX+1].Ch != 'X' { + t.Errorf("expected close button 'X' at x=%d, got %q", closeX+1, string(cells[0][closeX+1].Ch)) + } + } +} + +func TestCommentPanelRenderNoComments(t *testing.T) { + panel := NewCommentPanelWidget("Empty PR") + panel.SetComments(nil) + + w, h := 80, 24 + panel.SetRect(Rect{X: 0, Y: 0, W: w, H: h}) + cells := make([][]term.Cell, h) + for y := range cells { + cells[y] = make([]term.Cell, w) + } + surface := NewRenderSurface(cells, Rect{X: 0, Y: 0, W: w, H: h}) + panel.Render(surface) + + // Should show "No comments yet" message + found := false + for y := 0; y < h; y++ { + var line []rune + for x := 0; x < w; x++ { + if cells[y][x].Ch != 0 { + line = append(line, cells[y][x].Ch) + } + } + if strings.Contains(string(line), "No comments yet") { + found = true + break + } + } + if !found { + t.Error("expected 'No comments yet' message in empty panel") + } +} + +func TestCommentPanelRenderLoading(t *testing.T) { + panel := NewCommentPanelWidget("Loading PR") + panel.Loading = true + + w, h := 80, 24 + panel.SetRect(Rect{X: 0, Y: 0, W: w, H: h}) + cells := make([][]term.Cell, h) + for y := range cells { + cells[y] = make([]term.Cell, w) + } + surface := NewRenderSurface(cells, Rect{X: 0, Y: 0, W: w, H: h}) + panel.Render(surface) + + // Should show "Loading comments..." message + found := false + for y := 0; y < h; y++ { + var line []rune + for x := 0; x < w; x++ { + if cells[y][x].Ch != 0 { + line = append(line, cells[y][x].Ch) + } + } + if strings.Contains(string(line), "Loading comments") { + found = true + break + } + } + if !found { + t.Error("expected 'Loading comments...' message") + } +} + +func TestCommentPanelHandleEscape(t *testing.T) { + panel := NewCommentPanelWidget("Test") + closed := false + panel.OnClose = func() { closed = true } + + ev := tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone) + result := panel.HandleEvent(ev) + if result != EventConsumed { + t.Error("expected Escape to be consumed") + } + if !closed { + t.Error("expected OnClose to be called") + } +} + +func TestCommentPanelHandleScrollKeys(t *testing.T) { + panel := NewCommentPanelWidget("Test") + panel.scrollTop = 5 + + // Up arrow + ev := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.scrollTop != 4 { + t.Errorf("expected scrollTop=4 after Up, got %d", panel.scrollTop) + } + + // Down arrow + ev = tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.scrollTop != 5 { + t.Errorf("expected scrollTop=5 after Down, got %d", panel.scrollTop) + } + + // PgUp + panel.scrollTop = 15 + ev = tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.scrollTop != 5 { + t.Errorf("expected scrollTop=5 after PgUp, got %d", panel.scrollTop) + } + + // PgDn + ev = tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.scrollTop != 15 { + t.Errorf("expected scrollTop=15 after PgDn, got %d", panel.scrollTop) + } +} + +func TestCommentPanelHandleScrollUpClampsAtZero(t *testing.T) { + panel := NewCommentPanelWidget("Test") + panel.scrollTop = 0 + + ev := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.scrollTop != 0 { + t.Errorf("expected scrollTop to stay at 0, got %d", panel.scrollTop) + } +} + +func TestCommentPanelComposeMode(t *testing.T) { + panel := NewCommentPanelWidget("Test") + + // Press 'c' to enter compose mode + ev := tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone) + panel.HandleEvent(ev) + if !panel.composing { + t.Error("expected composing to be true after pressing 'c'") + } + + // Type a character + ev = tcell.NewEventKey(tcell.KeyRune, 'H', tcell.ModNone) + panel.HandleEvent(ev) + if panel.Input.Text != "H" { + t.Errorf("expected Input.Text='H', got %q", panel.Input.Text) + } + + // Escape exits compose mode + ev = tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone) + panel.HandleEvent(ev) + if panel.composing { + t.Error("expected composing to be false after Escape in compose mode") + } +} + +func TestCommentPanelSubmitComment(t *testing.T) { + panel := NewCommentPanelWidget("Test") + submitted := "" + panel.OnSubmit = func(body string) { submitted = body } + + // Enter compose mode + ev := tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone) + panel.HandleEvent(ev) + + // Type text + for _, ch := range "test comment" { + ev = tcell.NewEventKey(tcell.KeyRune, ch, tcell.ModNone) + panel.HandleEvent(ev) + } + + // Submit + ev = tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone) + panel.HandleEvent(ev) + if submitted != "test comment" { + t.Errorf("expected submitted='test comment', got %q", submitted) + } + if panel.composing { + t.Error("expected composing to be false after submit") + } + if panel.Input.Text != "" { + t.Error("expected input to be cleared after submit") + } +} + +func TestCommentPanelSubmitEmptyIgnored(t *testing.T) { + panel := NewCommentPanelWidget("Test") + called := false + panel.OnSubmit = func(body string) { called = true } + + // Enter compose mode + ev := tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone) + panel.HandleEvent(ev) + + // Submit empty + ev = tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone) + panel.HandleEvent(ev) + if called { + t.Error("expected OnSubmit not to be called for empty text") + } +} + +func TestCommentPanelQuitKey(t *testing.T) { + panel := NewCommentPanelWidget("Test") + closed := false + panel.OnClose = func() { closed = true } + + ev := tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone) + panel.HandleEvent(ev) + if !closed { + t.Error("expected OnClose to be called on 'q'") + } +} + +func TestCommentPanelWidth(t *testing.T) { + panel := NewCommentPanelWidget("Test") + + // Normal screen + w := panel.panelWidth(200) + if w != 80 { + t.Errorf("expected width=80 for 200-wide screen, got %d", w) + } + + // 40% of 100 = 40 + w = panel.panelWidth(100) + if w != 40 { + t.Errorf("expected width=40 for 100-wide screen, got %d", w) + } + + // Small screen + w = panel.panelWidth(50) + if w > 40 { + t.Errorf("expected width<=40 for 50-wide screen, got %d", w) + } +} + +func TestWrapText(t *testing.T) { + tests := []struct { + text string + width int + want int // expected number of lines + }{ + {"short", 80, 1}, + {"hello world", 5, 2}, + {"", 80, 1}, // empty string produces one empty line + {"line1\nline2", 80, 2}, + {"a b c d e f", 5, 2}, + } + for _, tt := range tests { + got := wrapText(tt.text, tt.width) + if len(got) != tt.want { + t.Errorf("wrapText(%q, %d) = %d lines, want %d (got: %v)", + tt.text, tt.width, len(got), tt.want, got) + } + } +} + +func TestWrapTextZeroWidth(t *testing.T) { + result := wrapText("text", 0) + if result != nil { + t.Errorf("expected nil for zero width, got %v", result) + } +} + +func TestFormatTimestamp(t *testing.T) { + // Valid ISO 8601 + ts := formatTimestamp("2020-01-01T00:00:00Z") + if ts == "" || ts == "2020-01-01T00:00:00Z" { + // Should have been formatted (it's old enough to show a date) + if ts != "Jan 1, 2020" { + t.Errorf("unexpected timestamp format: %q", ts) + } + } + + // Invalid timestamp returns as-is + ts = formatTimestamp("not a date") + if ts != "not a date" { + t.Errorf("expected invalid timestamp to pass through, got %q", ts) + } +} + +func TestCommentPanelInlineComment(t *testing.T) { + panel := NewCommentPanelWidget("Test PR") + panel.SetComments([]CommentItem{ + { + ID: 1, + Author: "reviewer", + Body: "Fix this line", + FilePath: "main.go", + Line: 42, + IsInline: true, + }, + }) + + w, h := 80, 24 + panel.SetRect(Rect{X: 0, Y: 0, W: w, H: h}) + cells := make([][]term.Cell, h) + for y := range cells { + cells[y] = make([]term.Cell, w) + } + surface := NewRenderSurface(cells, Rect{X: 0, Y: 0, W: w, H: h}) + panel.Render(surface) + + // Verify file reference is rendered + found := false + for y := 0; y < h; y++ { + var line []rune + for x := 0; x < w; x++ { + if cells[y][x].Ch != 0 { + line = append(line, cells[y][x].Ch) + } + } + if strings.Contains(string(line), "main.go:42") { + found = true + break + } + } + if !found { + t.Error("expected inline comment to show 'main.go:42' file reference") + } +} + +func TestCommentPanelCursorPosition(t *testing.T) { + panel := NewCommentPanelWidget("Test") + panel.SetRect(Rect{X: 0, Y: 0, W: 80, H: 24}) + + // Not composing - no cursor + _, _, visible := panel.CursorPosition() + if visible { + t.Error("expected cursor to be invisible when not composing") + } + + // Enter compose mode + ev := tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone) + panel.HandleEvent(ev) + + _, _, visible = panel.CursorPosition() + if !visible { + t.Error("expected cursor to be visible when composing") + } +} + +func TestCommentPanelFocusedInput(t *testing.T) { + panel := NewCommentPanelWidget("Test") + + // Not composing + if inp := panel.FocusedInput(); inp != nil { + t.Error("expected nil FocusedInput when not composing") + } + + // Composing + panel.composing = true + if inp := panel.FocusedInput(); inp == nil { + t.Error("expected non-nil FocusedInput when composing") + } +} + +func TestRenderCommentsMultiple(t *testing.T) { + panel := NewCommentPanelWidget("Test") + panel.SetComments([]CommentItem{ + {Author: "alice", Body: "First comment", Timestamp: "2024-01-01T00:00:00Z"}, + {Author: "bob", Body: "Second comment", Timestamp: "2024-01-02T00:00:00Z"}, + }) + + lines := panel.renderComments(40) + if len(lines) == 0 { + t.Fatal("expected rendered lines") + } + + // Should contain separator between comments + hasSeparator := false + for _, rl := range lines { + allBorder := true + nonEmpty := false + for _, cell := range rl.cells { + if cell.Ch != 0 { + nonEmpty = true + if cell.Style != term.StyleBorder { + allBorder = false + } + } + } + if nonEmpty && allBorder { + hasSeparator = true + break + } + } + if !hasSeparator { + t.Error("expected separator line between comments") + } +}