From baf1b953604082767f7dfee7cad775a6c92d796b Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 19 Jun 2026 20:22:54 -0700 Subject: [PATCH 1/2] feat: add PR review comments hub with full-screen modal UI Implement PR review comment reading and writing via a full-screen Review Hub modal dialog. Comments are fetched from GitHub and displayed organized by file, with inline review comments grouped under file headers and general comments in a separate section. Users can navigate with keyboard (j/k/arrows, n/p for sections), press Enter to jump to commented code, 'a' to add a general comment, and 'r' to reply inline. - GitHub API: FetchPRComments, AddPRComment, AddPRInlineComment - ReviewHubWidget: full-screen modal with scrollable comment list - Theme: CommentStyles with marker/user/body/file colors - Commands: pr.comments (Ctrl+K G), pr.addComment - Async fetch/post with event loop integration Co-Authored-By: Claude Opus 4.6 --- internal/app/app.go | 1 + internal/app/commands_git.go | 37 ++ internal/app/eventloop.go | 30 ++ internal/app/pr.go | 138 +++++++ internal/app/theme.go | 4 + internal/config/keybindings.go | 1 + internal/config/theme.go | 22 +- internal/github/github.go | 100 +++++ internal/github/github_test.go | 33 ++ internal/term/screen.go | 4 + internal/ui/reviewhub_widget.go | 586 +++++++++++++++++++++++++++ internal/ui/reviewhub_widget_test.go | 324 +++++++++++++++ 12 files changed, 1275 insertions(+), 5 deletions(-) create mode 100644 internal/ui/reviewhub_widget.go create mode 100644 internal/ui/reviewhub_widget_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 1b898e2d..86c2c85f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -75,6 +75,7 @@ type App struct { GitGutterGen int GitGutterTimer *time.Timer Version string + CurrentPR *ActivePR } func (a *App) KeyFor(cmd string) string { diff --git a/internal/app/commands_git.go b/internal/app/commands_git.go index 2f553391..f99f139d 100644 --- a/internal/app/commands_git.go +++ b/internal/app/commands_git.go @@ -341,4 +341,41 @@ func registerPRCommands(app *App) { Keywords: []string{"git", "pull request", "github"}, Handler: func() { app.Changes.RemovePRGroups() }, }) + + reg.Register(command.Command{ + ID: "pr.comments", Title: "Git: PR Comments", + Keywords: []string{"git", "pull request", "github", "review", "comments"}, + Handler: func() { + if app.Root.HasOverlay() { + return + } + if app.CurrentPR == nil { + app.StatusWarn("No active PR. Use 'Git: Review PR' first.") + return + } + app.FetchPRComments( + app.CurrentPR.Owner, + app.CurrentPR.Repo, + app.CurrentPR.Number, + app.CurrentPR.Title, + app.CurrentPR.HeadSHA, + ) + }, + }) + + reg.Register(command.Command{ + ID: "pr.addComment", Title: "Git: Add PR Comment", + Keywords: []string{"git", "pull request", "github", "comment", "review"}, + Handler: func() { + if app.CurrentPR == nil { + app.StatusWarn("No active PR. Use 'Git: Review PR' first.") + return + } + app.ShowInputDialog("Add PR Comment", "Type your comment...", "", func(body string) { + if body != "" { + app.AddPRGeneralComment(app.CurrentPR, body) + } + }) + }, + }) } diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index 7c33ef76..cfcde0da 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -275,11 +275,41 @@ func RunEventLoop( dv.FinishLoading() } } + case *PrCommentsFetchResult: + if v.Err != nil { + app.StatusError("Failed to fetch PR comments: " + v.Err.Error()) + } else { + app.CurrentPR = &ActivePR{ + Owner: v.Owner, + Repo: v.Repo, + Number: v.Number, + Title: v.Title, + HeadSHA: v.HeadSHA, + } + app.ShowReviewHub(v.Comments, v.Title, v.Number) + } + case *PrAddCommentResult: + if v.Err != nil { + app.StatusError("Failed to post comment: " + v.Err.Error()) + } else { + app.StatusNotify("Comment posted successfully") + // Refetch comments to show the new one + if app.CurrentPR != nil { + app.FetchPRComments(v.Owner, v.Repo, v.Number, v.Title, app.CurrentPR.HeadSHA) + } + } case *PrFetchResult: app.Changes.Loading = false if v.Err != nil { app.StatusError("PR fetch failed: " + v.Err.Error()) } else { + app.CurrentPR = &ActivePR{ + Owner: v.Info.Owner, + Repo: v.Info.Repo, + Number: v.Info.Number, + Title: v.Info.Title, + HeadSHA: v.Info.HeadSHA, + } var files []git.FileStatus for _, f := range v.Info.Files { files = append(files, git.FileStatus{ diff --git a/internal/app/pr.go b/internal/app/pr.go index a226bece..0d263eab 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" ) @@ -15,6 +16,27 @@ type PrFetchResult struct { Err error } +// PrCommentsFetchResult carries PR comments fetched asynchronously. +type PrCommentsFetchResult struct { + Owner string + Repo string + Number int + Title string + HeadSHA string + Comments []github.PRComment + Err error +} + +// PrAddCommentResult carries the result of adding a comment. +type PrAddCommentResult struct { + Err error + // Refetch triggers + Owner string + Repo string + Number int + Title string +} + type DiffContentResult struct { TabName string OldLines []string @@ -22,6 +44,122 @@ type DiffContentResult struct { Err error } +// ActivePR tracks the currently loaded PR for comment operations. +type ActivePR struct { + Owner string + Repo string + Number int + Title string + HeadSHA string +} + +// FetchPRComments fetches comments for a PR asynchronously. +func (a *App) FetchPRComments(owner, repo string, number int, title, headSHA string) { + a.StatusNotify(fmt.Sprintf("Fetching PR #%d comments...", number)) + go func() { + comments, err := github.FetchPRComments(owner, repo, number) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrCommentsFetchResult{ + Owner: owner, + Repo: repo, + Number: number, + Title: title, + HeadSHA: headSHA, + Comments: comments, + Err: err, + })) + }() +} + +// AddPRComment adds a general comment to the active PR. +func (a *App) AddPRGeneralComment(pr *ActivePR, body string) { + a.StatusNotify("Posting comment...") + go func() { + err := github.AddPRComment(pr.Owner, pr.Repo, pr.Number, body) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrAddCommentResult{ + Err: err, + Owner: pr.Owner, + Repo: pr.Repo, + Number: pr.Number, + Title: pr.Title, + })) + }() +} + +// AddPRInlineComment adds an inline comment on a specific file/line. +func (a *App) AddPRInlineComment(pr *ActivePR, body, path string, line int) { + a.StatusNotify("Posting inline comment...") + go func() { + err := github.AddPRInlineComment(pr.Owner, pr.Repo, pr.Number, body, path, line, pr.HeadSHA) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrAddCommentResult{ + Err: err, + Owner: pr.Owner, + Repo: pr.Repo, + Number: pr.Number, + Title: pr.Title, + })) + }() +} + +// ShowReviewHub displays the review hub overlay with all PR comments. +func (a *App) ShowReviewHub(comments []github.PRComment, title string, number int) { + // Convert github.PRComment to ui.ReviewComment + var uiComments []ui.ReviewComment + for _, c := range comments { + uiComments = append(uiComments, ui.ReviewComment{ + ID: c.ID, + Body: c.Body, + User: c.User, + CreatedAt: c.CreatedAt, + Path: c.Path, + Line: c.Line, + IsInline: c.IsInline, + }) + } + + hub := ui.NewReviewHubWidget(uiComments, title, number) + hub.Borders = a.Borders + hub.OnDismiss = func() { + a.DismissDialog() + } + hub.OnNavigate = func(path string, line int) { + a.DismissDialog() + // Try to find file in the workspace and open it + if path != "" { + a.EditorGroup.OpenFile(path) + if line > 0 { + a.EditorGroup.GoToLine(line) + } + a.Root.SetFocus(a.EditorGroup) + } + } + hub.OnAddComment = func() { + if a.CurrentPR == nil { + a.StatusWarn("No active PR") + return + } + a.DismissDialog() + a.ShowInputDialog("Add PR Comment", "Type your comment...", "", func(body string) { + if body != "" { + a.AddPRGeneralComment(a.CurrentPR, body) + } + }) + } + hub.OnAddInlineComment = func(path string, line int) { + if a.CurrentPR == nil { + a.StatusWarn("No active PR") + return + } + a.DismissDialog() + placeholder := fmt.Sprintf("Reply to %s:%d", path, line) + a.ShowInputDialog("Add Inline Comment", placeholder, "", func(body string) { + if body != "" { + a.AddPRInlineComment(a.CurrentPR, body, path, line) + } + }) + } + a.ShowDialog(hub) +} + func (a *App) FetchAndOpenPR(url string) { owner, repo, number, err := github.ParsePRURL(url) if err != nil { diff --git a/internal/app/theme.go b/internal/app/theme.go index 8179d904..79e88cf5 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -69,6 +69,10 @@ func BuildStyleMap(theme config.ThemeConfig) term.StyleMap { applyStyleDef(&m, term.StyleSuccess, theme.Success) applyStyleDef(&m, term.StyleDanger, theme.Danger) applyStyleDef(&m, term.StyleWarning, theme.Warning) + applyStyleDef(&m, term.StyleCommentMarker, theme.Comment.Marker) + applyStyleDef(&m, term.StyleCommentUser, theme.Comment.User) + applyStyleDef(&m, term.StyleCommentBody, theme.Comment.Body) + applyStyleDef(&m, term.StyleCommentFile, theme.Comment.File) applyDiagStyle(&m, term.StyleDiagError, theme.Editor.Diagnostics.Error) applyDiagStyle(&m, term.StyleDiagWarning, theme.Editor.Diagnostics.Warning) diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 3ec5bfea..a6c9c5f7 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -340,6 +340,7 @@ func DefaultKeybindings() []KeyBinding { {Key: "ctrl+k b", Command: "panel.toggle"}, {Key: "ctrl+k j", Command: "editor.joinLines"}, {Key: "ctrl+k y", Command: "view.keybindings"}, + {Key: "ctrl+k g", Command: "pr.comments"}, {Key: "ctrl+t", Command: "terminal.toggle"}, {Key: "alt+t", Command: "terminal.fullscreen"}, {Key: "f10", Command: "menu.file"}, diff --git a/internal/config/theme.go b/internal/config/theme.go index c12a656f..3b92eab0 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -185,6 +185,13 @@ type HoverStyles struct { Code StyleDef `json:"code"` } +type CommentStyles struct { + Marker StyleDef `json:"marker"` + User StyleDef `json:"user"` + Body StyleDef `json:"body"` + File StyleDef `json:"file"` +} + type ThemeConfig struct { Default StyleDef `json:"default"` Muted StyleDef `json:"muted"` @@ -200,6 +207,7 @@ type ThemeConfig struct { Input InputStyles `json:"input"` Hover HoverStyles `json:"hover"` Border StyleDef `json:"border"` + Comment CommentStyles `json:"comment,omitempty"` Diff DiffStyles `json:"diff"` Scrollbar StyleDef `json:"scrollbar"` Syntax SyntaxStyles `json:"syntax"` @@ -235,11 +243,11 @@ func DefaultTheme() ThemeConfig { Border: StyleDef{Fg: "#555555"}, Editor: EditorStyles{ - ActiveLine: StyleDef{Bg: "#282828"}, - Selection: StyleDef{Bg: "#282828"}, - LineNumber: StyleDef{Fg: "#999999"}, - SearchMatch: StyleDef{Bg: "#623800"}, - SearchActive: StyleDef{Bg: "#9e6a03"}, + ActiveLine: StyleDef{Bg: "#282828"}, + Selection: StyleDef{Bg: "#282828"}, + LineNumber: StyleDef{Fg: "#999999"}, + SearchMatch: StyleDef{Bg: "#623800"}, + SearchActive: StyleDef{Bg: "#9e6a03"}, BracketMatch: StyleDef{Bg: "#3a3a3a"}, BracketColors: []string{"yellow", "magenta", "blue"}, }, @@ -300,6 +308,10 @@ func (t *ThemeConfig) ResolveColors() { } fillFg(&t.Hover.Bold, t.Default.Fg) fillFg(&t.Hover.Code, t.Syntax.String.Fg) + fillFg(&t.Comment.Marker, "#569cd6") + fillFg(&t.Comment.User, "#dcdcaa") + fillFg(&t.Comment.Body, t.Default.Fg) + fillFg(&t.Comment.File, "#569cd6") } func fillFg(s *StyleDef, color string) { diff --git a/internal/github/github.go b/internal/github/github.go index f258ae89..a3d38f8d 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -133,6 +133,106 @@ func FetchFileContent(owner, repo, path, ref string) (string, error) { return string(out), nil } +// PRComment represents a comment on a pull request. +// IsInline distinguishes inline (review) comments from general (issue) comments. +type PRComment struct { + ID int + Body string + User string + CreatedAt string + Path string // file path (empty for general comments) + Line int // line number (0 for general comments) + IsInline bool +} + +// FetchPRComments retrieves both inline review comments and general issue +// comments for the given pull request. Inline comments come from the +// pulls/comments endpoint; general comments come from issues/comments. +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)}]`) + out, err := cmd.Output() + if err == nil && len(out) > 0 { + var inline []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"` + } + if err := json.Unmarshal(out, &inline); err == nil { + for _, c := range inline { + comments = append(comments, PRComment{ + ID: c.ID, + Body: c.Body, + User: c.User, + CreatedAt: c.CreatedAt, + Path: c.Path, + Line: c.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", + "--jq", `[.[] | {id: .id, body: .body, user: .user.login, created_at: .created_at}]`) + out, err = cmd.Output() + if err == nil && len(out) > 0 { + var general []struct { + ID int `json:"id"` + Body string `json:"body"` + User string `json:"user"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(out, &general); err == nil { + for _, c := range general { + comments = append(comments, PRComment{ + ID: c.ID, + Body: c.Body, + User: c.User, + CreatedAt: c.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) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("add comment failed: %s", string(out)) + } + return nil +} + +// AddPRInlineComment adds an inline review comment to a specific file and line. +func AddPRInlineComment(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, + "-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 inline 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..e18f3a9c 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -38,6 +38,39 @@ func TestParsePRURL(t *testing.T) { } } +func TestPRCommentType(t *testing.T) { + c := PRComment{ + ID: 1, + Body: "looks good", + User: "reviewer", + Path: "main.go", + Line: 42, + IsInline: true, + } + if c.ID != 1 { + t.Errorf("expected ID 1, got %d", c.ID) + } + if c.Path != "main.go" { + t.Errorf("expected path main.go, got %q", c.Path) + } + if !c.IsInline { + t.Error("expected IsInline to be true") + } + + general := PRComment{ + ID: 2, + Body: "general comment", + User: "user", + IsInline: false, + } + if general.IsInline { + t.Error("expected IsInline to be false for general comment") + } + if general.Path != "" { + t.Errorf("expected empty path for general comment, got %q", general.Path) + } +} + func TestSplitMultiFileDiff(t *testing.T) { unified := `diff --git a/file1.go b/file1.go --- a/file1.go diff --git a/internal/term/screen.go b/internal/term/screen.go index d2d05e49..af42ea09 100644 --- a/internal/term/screen.go +++ b/internal/term/screen.go @@ -58,6 +58,10 @@ const ( StyleGutterAdded StyleGutterModified StyleGutterDeleted + StyleCommentMarker + StyleCommentUser + StyleCommentBody + StyleCommentFile ) // DirectColor holds an RGBA color for terminal emulator output. diff --git a/internal/ui/reviewhub_widget.go b/internal/ui/reviewhub_widget.go new file mode 100644 index 00000000..5e71b8e4 --- /dev/null +++ b/internal/ui/reviewhub_widget.go @@ -0,0 +1,586 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/eugenioenko/ttt/internal/term" + + "github.com/gdamore/tcell/v2" +) + +// ReviewComment mirrors github.PRComment but lives in the ui package to +// avoid a circular import. +type ReviewComment struct { + ID int + Body string + User string + CreatedAt string + Path string + Line int + IsInline bool +} + +// reviewRow represents a single rendered row in the review hub. Each row +// is either a section header (file group), a comment body line, or a +// separator. Tracking the type lets us map clicks/navigation back to the +// originating comment. +type reviewRow struct { + kind rowKind + text string + style term.Style + boldPrefix int // number of leading runes to render bold + commentIdx int // index into Comments slice (-1 for non-comment rows) + indent int // left indent in cells +} + +type rowKind int + +const ( + rowHeader rowKind = iota // file path header + rowSeparator // blank/divider + rowUser // "@ user 2024-01-15" + rowBody // comment body text + rowGeneral // "General Comments" header +) + +// ReviewHubWidget shows all PR review comments in a full-screen modal, +// organized by file. Users can scroll through comments, press Enter to +// navigate to a comment's location in the editor, or press 'a' to add +// a new comment. +type ReviewHubWidget struct { + BaseWidget + + Comments []ReviewComment + PRTitle string + PRNumber int + Borders *term.BorderSet + OnDismiss func() + + // OnNavigate is called when the user activates a comment to jump + // to its file/line in the editor. + OnNavigate func(path string, line int) + + // OnAddComment is called when the user wants to add a general comment. + OnAddComment func() + + // OnAddInlineComment is called with the selected comment's file+line + // so the app can prompt for an inline reply. + OnAddInlineComment func(path string, line int) + + rows []reviewRow + list SelectableList + rowsDirty bool + + // layout values (single source of truth) + boxX, boxY, boxW, boxH int + contentY int // Y of first content row + visibleRows int // number of visible content rows +} + +// NewReviewHubWidget creates a new review hub with the given comments. +func NewReviewHubWidget(comments []ReviewComment, prTitle string, prNumber int) *ReviewHubWidget { + w := &ReviewHubWidget{ + Comments: comments, + PRTitle: prTitle, + PRNumber: prNumber, + rowsDirty: true, + } + return w +} + +func (w *ReviewHubWidget) Focusable() bool { return true } + +// buildRows converts the flat comment list into a list of displayable rows +// grouped by file path. +func (w *ReviewHubWidget) buildRows() { + w.rows = nil + + // Separate inline vs general comments + var inline []ReviewComment + var general []ReviewComment + for _, c := range w.Comments { + if c.IsInline { + inline = append(inline, c) + } else { + general = append(general, c) + } + } + + // Group inline comments by file + type fileGroup struct { + path string + comments []ReviewComment + } + seen := map[string]int{} + var groups []fileGroup + for _, c := range inline { + if idx, ok := seen[c.Path]; ok { + groups[idx].comments = append(groups[idx].comments, c) + } else { + seen[c.Path] = len(groups) + groups = append(groups, fileGroup{path: c.Path, comments: []ReviewComment{c}}) + } + } + + commentIdx := 0 + + // Render inline comments grouped by file + for gi, group := range groups { + if gi > 0 { + w.rows = append(w.rows, reviewRow{kind: rowSeparator, commentIdx: -1}) + } + + // File header + w.rows = append(w.rows, reviewRow{ + kind: rowHeader, + text: " " + group.path, + style: term.StyleCommentFile, + commentIdx: -1, + }) + + for _, c := range group.comments { + ci := commentIdx + commentIdx++ + + // User + date line + dateStr := formatCommentDate(c.CreatedAt) + userLine := fmt.Sprintf(" @%s L%d %s", c.User, c.Line, dateStr) + w.rows = append(w.rows, reviewRow{ + kind: rowUser, + text: userLine, + style: term.StyleCommentUser, + boldPrefix: len([]rune(" @" + c.User)), + commentIdx: ci, + indent: 2, + }) + + // Body lines + for _, line := range strings.Split(c.Body, "\n") { + w.rows = append(w.rows, reviewRow{ + kind: rowBody, + text: " " + line, + style: term.StyleCommentBody, + commentIdx: ci, + indent: 4, + }) + } + } + } + + // General comments section + if len(general) > 0 { + if len(w.rows) > 0 { + w.rows = append(w.rows, reviewRow{kind: rowSeparator, commentIdx: -1}) + } + w.rows = append(w.rows, reviewRow{ + kind: rowGeneral, + text: " General Comments", + style: term.StyleCommentFile, + commentIdx: -1, + }) + + for _, c := range general { + ci := commentIdx + commentIdx++ + + dateStr := formatCommentDate(c.CreatedAt) + userLine := fmt.Sprintf(" @%s %s", c.User, dateStr) + w.rows = append(w.rows, reviewRow{ + kind: rowUser, + text: userLine, + style: term.StyleCommentUser, + boldPrefix: len([]rune(" @" + c.User)), + commentIdx: ci, + indent: 2, + }) + + for _, line := range strings.Split(c.Body, "\n") { + w.rows = append(w.rows, reviewRow{ + kind: rowBody, + text: " " + line, + style: term.StyleCommentBody, + commentIdx: ci, + indent: 4, + }) + } + } + } + + if len(w.rows) == 0 { + w.rows = append(w.rows, reviewRow{ + kind: rowBody, + text: " No comments on this PR", + style: term.StyleMuted, + commentIdx: -1, + }) + } + + w.rowsDirty = false +} + +func (w *ReviewHubWidget) Render(surface *RenderSurface) { + if w.rowsDirty { + w.buildRows() + } + + sw, sh := surface.Size() + + // Full-screen-ish box with some margin + marginX := 4 + marginY := 1 + if sw < 60 { + marginX = 1 + } + if sh < 20 { + marginY = 0 + } + w.boxX = marginX + w.boxY = marginY + w.boxW = sw - 2*marginX + w.boxH = sh - 2*marginY + if w.boxW < 20 { + w.boxW = 20 + } + if w.boxH < 8 { + w.boxH = 8 + } + + b := term.DoubleBorderSet() + if w.Borders != nil { + b = *w.Borders + } + + // Clear and draw border + surface.ClearRect(w.boxX, w.boxY, w.boxW, w.boxH, term.StylePaletteItem) + surface.DrawBorder(w.boxX, w.boxY, w.boxW, w.boxH, b, term.StyleBorder) + + innerW := w.boxW - 2 + + // Title bar: "PR #123: Title" + titleText := fmt.Sprintf(" PR #%d: %s ", w.PRNumber, w.PRTitle) + titleRunes := []rune(titleText) + if len(titleRunes) > innerW { + titleRunes = append(titleRunes[:innerW-1], '~') + } + titleX := w.boxX + (w.boxW-len(titleRunes))/2 + for i, ch := range titleRunes { + surface.SetCell(titleX+i, w.boxY, term.Cell{Ch: ch, Style: term.StyleBorder}) + } + + // Subtitle: comment count + help + totalComments := len(w.Comments) + subtitle := fmt.Sprintf(" %d comments ", totalComments) + surface.DrawText(w.boxX+2, w.boxY+1, subtitle, w.boxX+w.boxW-2, term.StyleMuted) + + // Help line at top-right + helpText := "[a]dd [Enter]go [Esc]close" + helpX := w.boxX + w.boxW - 2 - len([]rune(helpText)) + if helpX > w.boxX+2+len([]rune(subtitle)) { + surface.DrawText(helpX, w.boxY+1, helpText, w.boxX+w.boxW-2, term.StyleMuted) + } + + // Separator under subtitle + sepY := w.boxY + 2 + for x := w.boxX + 1; x < w.boxX+w.boxW-1; x++ { + surface.SetCell(x, sepY, term.Cell{Ch: b.Horizontal, Style: term.StyleBorder}) + } + + // Content area + w.contentY = w.boxY + 3 + w.visibleRows = w.boxH - 4 // border + title + sep + bottom border + if w.visibleRows < 1 { + w.visibleRows = 1 + } + + w.list.ClampSelected(len(w.rows)) + w.list.EnsureVisible(w.visibleRows) + + for i := 0; i < w.visibleRows; i++ { + rowIdx := w.list.ScrollTop + i + if rowIdx >= len(w.rows) { + break + } + row := w.rows[rowIdx] + y := w.contentY + i + + isSelected := rowIdx == w.list.Selected + style := row.style + if isSelected { + // Draw selection highlight + surface.ClearRect(w.boxX+1, y, innerW, 1, term.StylePaletteSelected) + style = term.StylePaletteSelected + } + + // Draw row content + text := row.text + runes := []rune(text) + if len(runes) > innerW { + runes = append(runes[:innerW-1], '~') + } + + switch row.kind { + case rowHeader, rowGeneral: + // File headers get a distinctive marker + marker := " " + if row.kind == rowGeneral { + marker = " " + } + markerStyle := term.StyleCommentMarker + if isSelected { + markerStyle = term.StylePaletteSelected + } + mx := w.boxX + 1 + for j, ch := range []rune(marker) { + surface.SetCell(mx+j, y, term.Cell{Ch: ch, Style: markerStyle}) + } + textX := mx + len([]rune(marker)) + for j, ch := range runes { + surface.SetCell(textX+j, y, term.Cell{Ch: ch, Style: style}) + } + + case rowUser: + // User line: bold the @username portion + for j, ch := range runes { + s := style + if j < row.boldPrefix && !isSelected { + s = term.StyleCommentUser + } + surface.SetCell(w.boxX+1+j, y, term.Cell{Ch: ch, Style: s}) + } + + case rowBody: + for j, ch := range runes { + surface.SetCell(w.boxX+1+j, y, term.Cell{Ch: ch, Style: style}) + } + + case rowSeparator: + // leave blank (already cleared) + } + } + + // Scrollbar + if len(w.rows) > w.visibleRows && w.visibleRows > 1 { + sbX := w.boxX + w.boxW - 2 + ratio := float64(w.list.ScrollTop) / float64(len(w.rows)-w.visibleRows) + thumbY := w.contentY + int(ratio*float64(w.visibleRows-1)) + for y := w.contentY; y < w.contentY+w.visibleRows; y++ { + style := term.StyleScrollbar + if y == thumbY { + style = term.StyleScrollbarThumb + } + surface.SetCell(sbX, y, term.Cell{Ch: ' ', Style: style}) + } + } +} + +func (w *ReviewHubWidget) HandleEvent(ev tcell.Event) EventResult { + switch tev := ev.(type) { + case *tcell.EventMouse: + btn := tev.Buttons() + _, my := tev.Position() + + if btn&tcell.Button1 != 0 { + if my >= w.contentY && my < w.contentY+w.visibleRows { + idx := w.list.ScrollTop + (my - w.contentY) + if idx >= 0 && idx < len(w.rows) { + w.list.Selected = idx + w.activateSelected() + } + } + return EventConsumed + } + if btn&tcell.WheelUp != 0 { + w.list.ScrollTop -= 3 + if w.list.ScrollTop < 0 { + w.list.ScrollTop = 0 + } + return EventConsumed + } + if btn&tcell.WheelDown != 0 { + max := len(w.rows) - w.visibleRows + if max < 0 { + max = 0 + } + w.list.ScrollTop += 3 + if w.list.ScrollTop > max { + w.list.ScrollTop = max + } + return EventConsumed + } + return EventConsumed + + case *tcell.EventKey: + switch tev.Key() { + case tcell.KeyEscape: + if w.OnDismiss != nil { + w.OnDismiss() + } + return EventConsumed + + case tcell.KeyUp: + if w.list.Selected > 0 { + w.list.Selected-- + } + return EventConsumed + + case tcell.KeyDown: + if w.list.Selected < len(w.rows)-1 { + w.list.Selected++ + } + return EventConsumed + + case tcell.KeyPgUp: + w.list.Selected -= w.visibleRows + if w.list.Selected < 0 { + w.list.Selected = 0 + } + return EventConsumed + + case tcell.KeyPgDn: + w.list.Selected += w.visibleRows + if w.list.Selected >= len(w.rows) { + w.list.Selected = len(w.rows) - 1 + } + return EventConsumed + + case tcell.KeyHome: + w.list.Selected = 0 + w.list.ScrollTop = 0 + return EventConsumed + + case tcell.KeyEnd: + w.list.Selected = len(w.rows) - 1 + return EventConsumed + + case tcell.KeyEnter: + w.activateSelected() + return EventConsumed + + case tcell.KeyRune: + switch tev.Rune() { + case 'a', 'A': + // Add comment + w.handleAddComment() + return EventConsumed + case 'r', 'R': + // Reply to inline comment + w.handleReplyInline() + return EventConsumed + case 'j': + if w.list.Selected < len(w.rows)-1 { + w.list.Selected++ + } + return EventConsumed + case 'k': + if w.list.Selected > 0 { + w.list.Selected-- + } + return EventConsumed + case 'n', 'N': + // Jump to next file section + w.jumpNextSection() + return EventConsumed + case 'p', 'P': + // Jump to previous file section + w.jumpPrevSection() + return EventConsumed + } + } + return EventConsumed + } + + return EventConsumed +} + +// activateSelected navigates to the file/line of the selected comment. +func (w *ReviewHubWidget) activateSelected() { + if w.list.Selected < 0 || w.list.Selected >= len(w.rows) { + return + } + row := w.rows[w.list.Selected] + if row.commentIdx < 0 { + return + } + + // Find the original comment + comment := w.commentByRowIdx(row.commentIdx) + if comment == nil { + return + } + if comment.IsInline && comment.Path != "" && w.OnNavigate != nil { + w.OnNavigate(comment.Path, comment.Line) + } +} + +func (w *ReviewHubWidget) handleAddComment() { + if w.OnAddComment != nil { + w.OnAddComment() + } +} + +func (w *ReviewHubWidget) handleReplyInline() { + if w.list.Selected < 0 || w.list.Selected >= len(w.rows) { + return + } + row := w.rows[w.list.Selected] + if row.commentIdx < 0 { + return + } + comment := w.commentByRowIdx(row.commentIdx) + if comment == nil || !comment.IsInline { + return + } + if w.OnAddInlineComment != nil { + w.OnAddInlineComment(comment.Path, comment.Line) + } +} + +// commentByRowIdx returns the comment at the given sequential index +// (inline comments first, then general). +func (w *ReviewHubWidget) commentByRowIdx(idx int) *ReviewComment { + count := 0 + for i := range w.Comments { + if w.Comments[i].IsInline { + if count == idx { + return &w.Comments[i] + } + count++ + } + } + for i := range w.Comments { + if !w.Comments[i].IsInline { + if count == idx { + return &w.Comments[i] + } + count++ + } + } + return nil +} + +func (w *ReviewHubWidget) jumpNextSection() { + for i := w.list.Selected + 1; i < len(w.rows); i++ { + if w.rows[i].kind == rowHeader || w.rows[i].kind == rowGeneral { + w.list.Selected = i + return + } + } +} + +func (w *ReviewHubWidget) jumpPrevSection() { + for i := w.list.Selected - 1; i >= 0; i-- { + if w.rows[i].kind == rowHeader || w.rows[i].kind == rowGeneral { + w.list.Selected = i + return + } + } +} + +// formatCommentDate extracts a readable date from an ISO 8601 timestamp. +func formatCommentDate(iso string) string { + if len(iso) >= 10 { + return iso[:10] + } + return iso +} diff --git a/internal/ui/reviewhub_widget_test.go b/internal/ui/reviewhub_widget_test.go new file mode 100644 index 00000000..352cf1b4 --- /dev/null +++ b/internal/ui/reviewhub_widget_test.go @@ -0,0 +1,324 @@ +package ui + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func makeReviewComments() []ReviewComment { + return []ReviewComment{ + {ID: 1, Body: "Fix this bug", User: "alice", CreatedAt: "2024-01-15T10:00:00Z", Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Body: "Looks good", User: "bob", CreatedAt: "2024-01-15T11:00:00Z", Path: "main.go", Line: 20, IsInline: true}, + {ID: 3, Body: "Check error handling", User: "alice", CreatedAt: "2024-01-15T12:00:00Z", Path: "util.go", Line: 5, IsInline: true}, + {ID: 4, Body: "LGTM overall", User: "carol", CreatedAt: "2024-01-16T09:00:00Z", IsInline: false}, + } +} + +func TestNewReviewHubWidget(t *testing.T) { + comments := makeReviewComments() + w := NewReviewHubWidget(comments, "Fix bugs", 42) + + if w.PRTitle != "Fix bugs" { + t.Errorf("expected title 'Fix bugs', got %q", w.PRTitle) + } + if w.PRNumber != 42 { + t.Errorf("expected number 42, got %d", w.PRNumber) + } + if len(w.Comments) != 4 { + t.Errorf("expected 4 comments, got %d", len(w.Comments)) + } + if !w.Focusable() { + t.Error("expected Focusable() to return true") + } +} + +func TestReviewHubBuildRows(t *testing.T) { + comments := makeReviewComments() + w := NewReviewHubWidget(comments, "Fix bugs", 42) + w.buildRows() + + if len(w.rows) == 0 { + t.Fatal("expected rows to be built") + } + + // Should have at least: file header for main.go, comments, file header for util.go, comments, general header, comment + hasMainHeader := false + hasUtilHeader := false + hasGeneralHeader := false + bodyCount := 0 + userCount := 0 + + for _, row := range w.rows { + switch row.kind { + case rowHeader: + if row.text == " main.go" { + hasMainHeader = true + } + if row.text == " util.go" { + hasUtilHeader = true + } + case rowGeneral: + hasGeneralHeader = true + case rowBody: + bodyCount++ + case rowUser: + userCount++ + } + } + + if !hasMainHeader { + t.Error("expected main.go file header") + } + if !hasUtilHeader { + t.Error("expected util.go file header") + } + if !hasGeneralHeader { + t.Error("expected General Comments header") + } + // 3 inline + 1 general = 4 user rows + if userCount != 4 { + t.Errorf("expected 4 user rows, got %d", userCount) + } + // Each comment has 1 body line = 4 body rows + if bodyCount != 4 { + t.Errorf("expected 4 body rows, got %d", bodyCount) + } +} + +func TestReviewHubEmptyComments(t *testing.T) { + w := NewReviewHubWidget(nil, "Empty PR", 1) + w.buildRows() + + if len(w.rows) != 1 { + t.Fatalf("expected 1 row for empty comments, got %d", len(w.rows)) + } + if w.rows[0].text != " No comments on this PR" { + t.Errorf("unexpected empty text: %q", w.rows[0].text) + } +} + +func TestReviewHubRender(t *testing.T) { + comments := makeReviewComments() + w := NewReviewHubWidget(comments, "Fix bugs", 42) + surface := makeSurface(80, 24) + w.SetRect(Rect{X: 0, Y: 0, W: 80, H: 24}) + w.Render(surface) + + // After render, layout values should be set + if w.boxW <= 0 || w.boxH <= 0 { + t.Errorf("expected positive box dimensions, got %dx%d", w.boxW, w.boxH) + } + if w.visibleRows <= 0 { + t.Errorf("expected positive visibleRows, got %d", w.visibleRows) + } +} + +func TestReviewHubKeyboardNav(t *testing.T) { + comments := makeReviewComments() + w := NewReviewHubWidget(comments, "Fix bugs", 42) + w.buildRows() + + initial := w.list.Selected + + // Down + w.HandleEvent(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) + if w.list.Selected != initial+1 { + t.Errorf("down: expected %d, got %d", initial+1, w.list.Selected) + } + + // Up + w.HandleEvent(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) + if w.list.Selected != initial { + t.Errorf("up: expected %d, got %d", initial, w.list.Selected) + } + + // j/k vim-style navigation + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone)) + if w.list.Selected != initial+1 { + t.Errorf("j: expected %d, got %d", initial+1, w.list.Selected) + } + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone)) + if w.list.Selected != initial { + t.Errorf("k: expected %d, got %d", initial, w.list.Selected) + } + + // Home + w.list.Selected = 5 + w.HandleEvent(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) + if w.list.Selected != 0 { + t.Errorf("home: expected 0, got %d", w.list.Selected) + } + + // End + w.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)) + if w.list.Selected != len(w.rows)-1 { + t.Errorf("end: expected %d, got %d", len(w.rows)-1, w.list.Selected) + } +} + +func TestReviewHubEscapeDismisses(t *testing.T) { + w := NewReviewHubWidget(nil, "Test", 1) + w.buildRows() + + dismissed := false + w.OnDismiss = func() { dismissed = true } + + w.HandleEvent(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) + if !dismissed { + t.Error("escape should call OnDismiss") + } +} + +func TestReviewHubEnterNavigates(t *testing.T) { + comments := []ReviewComment{ + {ID: 1, Body: "Fix", User: "alice", Path: "main.go", Line: 10, IsInline: true}, + } + w := NewReviewHubWidget(comments, "Test", 1) + w.buildRows() + + var navPath string + var navLine int + w.OnNavigate = func(path string, line int) { + navPath = path + navLine = line + } + + // Move to the user row (first selectable row with a comment) + for i, row := range w.rows { + if row.commentIdx >= 0 { + w.list.Selected = i + break + } + } + + w.HandleEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + if navPath != "main.go" { + t.Errorf("expected navigate to main.go, got %q", navPath) + } + if navLine != 10 { + t.Errorf("expected navigate to line 10, got %d", navLine) + } +} + +func TestReviewHubAddCommentShortcut(t *testing.T) { + w := NewReviewHubWidget(nil, "Test", 1) + w.buildRows() + + addCalled := false + w.OnAddComment = func() { addCalled = true } + + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) + if !addCalled { + t.Error("'a' should call OnAddComment") + } +} + +func TestReviewHubSectionJump(t *testing.T) { + comments := makeReviewComments() + w := NewReviewHubWidget(comments, "Fix bugs", 42) + w.buildRows() + + // Start at first row (should be a header) + w.list.Selected = 0 + + // Jump to next section + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'n', tcell.ModNone)) + if w.list.Selected == 0 { + t.Error("'n' should move to next section") + } + row := w.rows[w.list.Selected] + if row.kind != rowHeader && row.kind != rowGeneral { + t.Errorf("expected header row, got kind %d", row.kind) + } + + // Jump back + savedPos := w.list.Selected + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'p', tcell.ModNone)) + if w.list.Selected >= savedPos { + t.Error("'p' should move to previous section") + } +} + +func TestReviewHubMultilineComment(t *testing.T) { + comments := []ReviewComment{ + {ID: 1, Body: "Line 1\nLine 2\nLine 3", User: "alice", Path: "f.go", Line: 1, IsInline: true}, + } + w := NewReviewHubWidget(comments, "Test", 1) + w.buildRows() + + bodyCount := 0 + for _, row := range w.rows { + if row.kind == rowBody { + bodyCount++ + } + } + if bodyCount != 3 { + t.Errorf("expected 3 body rows for multiline comment, got %d", bodyCount) + } +} + +func TestReviewHubAllEventsConsumed(t *testing.T) { + w := NewReviewHubWidget(nil, "Test", 1) + w.buildRows() + + // All key events should be consumed (modal widget) + result := w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)) + if result != EventConsumed { + t.Error("unhandled key should still return EventConsumed") + } + + result = w.HandleEvent(tcell.NewEventKey(tcell.KeyF1, 0, tcell.ModNone)) + if result != EventConsumed { + t.Error("unhandled special key should still return EventConsumed") + } +} + +func TestFormatCommentDate(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"2024-01-15T10:00:00Z", "2024-01-15"}, + {"2024-12-31", "2024-12-31"}, + {"short", "short"}, + {"", ""}, + } + for _, tt := range tests { + got := formatCommentDate(tt.input) + if got != tt.want { + t.Errorf("formatCommentDate(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestReviewHubReplyInline(t *testing.T) { + comments := []ReviewComment{ + {ID: 1, Body: "Fix", User: "alice", Path: "main.go", Line: 10, IsInline: true}, + } + w := NewReviewHubWidget(comments, "Test", 1) + w.buildRows() + + var replyPath string + var replyLine int + w.OnAddInlineComment = func(path string, line int) { + replyPath = path + replyLine = line + } + + // Move to a row with a comment + for i, row := range w.rows { + if row.commentIdx >= 0 { + w.list.Selected = i + break + } + } + + w.HandleEvent(tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone)) + if replyPath != "main.go" { + t.Errorf("expected reply to main.go, got %q", replyPath) + } + if replyLine != 10 { + t.Errorf("expected reply to line 10, got %d", replyLine) + } +} From e30d1e703d4326e90a9992d521ae3a721df300b5 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 19 Jun 2026 21:02:23 -0700 Subject: [PATCH 2/2] fix: update StyleCount to match actual style constant count StyleCount was 55 but 4 new comment styles were added (indices 55-58), causing index-out-of-bounds panic in BuildStyleMap on startup. Co-Authored-By: Claude Opus 4.6 --- internal/term/tcell_screen.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/term/tcell_screen.go b/internal/term/tcell_screen.go index 3b8b8c63..2263baa4 100644 --- a/internal/term/tcell_screen.go +++ b/internal/term/tcell_screen.go @@ -4,7 +4,7 @@ import ( "github.com/gdamore/tcell/v2" ) -const StyleCount = 55 +const StyleCount = 59 type StyleMap [StyleCount]tcell.Style