From 11b6ca269307c49b22d798a2031d1c7a1efffef1 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Fri, 19 Jun 2026 20:40:22 -0700 Subject: [PATCH] feat: review inbox with state-tracked PR comments Add a Review Inbox sidebar panel that provides a better PR review experience than GitHub's web UI. Comments are tracked as tasks with states (Open -> Addressed -> Verified -> Closed), and the editor shows live code with gutter markers on commented lines. Key features: - GitHub API layer for fetching/posting PR review comments - Review Inbox sidebar panel with file-grouped comment threads - Comment state machine: Open, Addressed, Verified, Dismissed - Auto-detection of "addressed" comments via git log - Gutter markers on commented lines (diamond indicators) - Next/prev unresolved navigation (Ctrl+K N) - Status bar progress display (e.g. "3/7 resolved") - State persistence via .ttt-review-state.json - Comments auto-load when a PR is opened via Changes Co-Authored-By: Claude Opus 4.6 --- internal/app/app.go | 1 + internal/app/callbacks.go | 58 +++ internal/app/commands.go | 1 + internal/app/commands_review.go | 62 +++ internal/app/eventloop.go | 10 + internal/app/review.go | 381 ++++++++++++++ internal/app/theme.go | 1 + internal/app/widgets.go | 3 + internal/config/keybindings.go | 2 + internal/github/github.go | 138 +++++ internal/github/github_test.go | 204 ++++++++ internal/github/review_state.go | 132 +++++ internal/github/review_state_test.go | 158 ++++++ internal/term/screen.go | 1 + internal/term/screen_test.go | 1 + internal/ui/editor_widget.go | 8 + internal/ui/review_inbox_widget.go | 644 ++++++++++++++++++++++++ internal/ui/review_inbox_widget_test.go | 260 ++++++++++ internal/ui/statusbar_widget.go | 23 +- internal/view/statusbar.go | 31 +- tests/e2e/sidebar_test.go | 8 +- 21 files changed, 2100 insertions(+), 27 deletions(-) create mode 100644 internal/app/commands_review.go create mode 100644 internal/app/review.go create mode 100644 internal/github/review_state.go create mode 100644 internal/github/review_state_test.go create mode 100644 internal/ui/review_inbox_widget.go create mode 100644 internal/ui/review_inbox_widget_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 1b898e2d..a892be5f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -63,6 +63,7 @@ type App struct { HoverGen uint64 LastHoverLine int LastHoverCol int + ReviewInbox *ui.ReviewInboxWidget Problems *ui.ProblemsWidget References *ui.ReferencesWidget AllDiagnostics map[string][]ui.Diagnostic diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index adc96283..3f8993f6 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -51,6 +51,13 @@ func (a *App) ShowSidebarMoreMenu(sx, sy int) { ui.MenuSep(), {Label: "Help", Command: "changes.help"}, } + case "inbox": + items = []ui.ContextMenuItem{ + {Label: "Refresh", Command: "review.refresh"}, + ui.MenuSep(), + {Label: "Next Unresolved", Command: "review.nextUnresolved"}, + {Label: "Previous Unresolved", Command: "review.prevUnresolved"}, + } } if len(items) > 0 { openContextMenu(a, items, sx, sy) @@ -513,6 +520,57 @@ func registerWidgetCallbacks(app *App) { app.Changes.OnCommit = app.CommitChanges app.Changes.OnConfirmDiscard = app.ConfirmDiscard + // Review Inbox callbacks + app.ReviewInbox.OnOpenFile = func(path string, line int) { + absPath := app.resolveCommentPath(path) + app.EditorGroup.OpenFile(absPath) + if line > 0 { + app.EditorGroup.GoToLine(line) + } + app.FocusEditorIfEnabled() + } + + app.ReviewInbox.OnMarkVerified = func(commentID int) { + app.setCommentState(commentID, github.StateVerified) + } + + app.ReviewInbox.OnMarkDismissed = func(commentID int) { + app.setCommentState(commentID, github.StateDismissed) + } + + app.ReviewInbox.OnReopen = func(commentID int) { + app.setCommentState(commentID, github.StateOpen) + } + + app.ReviewInbox.OnAddReply = func(comment github.PRComment) { + if app.ReviewInbox.PRNumber == 0 { + return + } + app.ShowInputDialog( + fmt.Sprintf("Reply to @%s", comment.User), + "Enter your reply...", + "", + func(body string) { + if body == "" { + return + } + go func() { + err := github.AddPRComment( + app.ReviewInbox.PROwner, + app.ReviewInbox.PRRepo, + app.ReviewInbox.PRNumber, + body, + ) + app.Screen.PostEvent(tcell.NewEventInterrupt(&reviewCommentPostResult{err: err})) + }() + }, + ) + } + + app.ReviewInbox.OnRefresh = func() { + app.ReviewRefreshComments() + } + app.ContentSplit.OnResize = func(height int) { if height <= 0 { app.ContentSplit.ShowBottom = false diff --git a/internal/app/commands.go b/internal/app/commands.go index 96a350db..75adcc1f 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -17,6 +17,7 @@ func RegisterCommands(app *App) { registerGitCommands(app) registerWorkspaceCommands(app) registerPRCommands(app) + registerReviewCommands(app) registerHelpCommands(app) registerOptionsCommands(app) registerSettingsCommands(app) diff --git a/internal/app/commands_review.go b/internal/app/commands_review.go new file mode 100644 index 00000000..cbef70f3 --- /dev/null +++ b/internal/app/commands_review.go @@ -0,0 +1,62 @@ +package app + +import ( + "github.com/eugenioenko/ttt/internal/command" +) + +func registerReviewCommands(app *App) { + reg := app.Reg + + reg.Register(command.Command{ + ID: "sidebar.inbox", + Title: "Show Review Inbox", + Keywords: []string{"view", "review", "pr", "comments", "inbox"}, + Handler: func() { + app.ShowPanel("inbox", app.ReviewInbox) + }, + }) + + reg.Register(command.Command{ + ID: "review.showInbox", + Title: "Review: Show Inbox", + Keywords: []string{"review", "pr", "comments"}, + Handler: func() { + app.ShowPanel("inbox", app.ReviewInbox) + }, + }) + + reg.Register(command.Command{ + ID: "review.nextUnresolved", + Title: "Review: Next Unresolved Comment", + Keywords: []string{"review", "pr", "comment", "next"}, + Handler: app.ReviewNextUnresolved, + }) + + reg.Register(command.Command{ + ID: "review.prevUnresolved", + Title: "Review: Previous Unresolved Comment", + Keywords: []string{"review", "pr", "comment", "previous"}, + Handler: app.ReviewPrevUnresolved, + }) + + reg.Register(command.Command{ + ID: "review.markVerified", + Title: "Review: Mark Comment Verified", + Keywords: []string{"review", "pr", "comment", "verify", "resolve"}, + Handler: app.ReviewMarkVerified, + }) + + reg.Register(command.Command{ + ID: "review.addInlineComment", + Title: "Review: Add Inline Comment", + Keywords: []string{"review", "pr", "comment", "add"}, + Handler: app.ReviewAddInlineComment, + }) + + reg.Register(command.Command{ + ID: "review.refresh", + Title: "Review: Refresh Comments", + Keywords: []string{"review", "pr", "comment", "refresh"}, + Handler: app.ReviewRefreshComments, + }) +} diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index 7c33ef76..c81a10d3 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -97,8 +97,12 @@ func RunEventLoop( if filePath != lastGutterFile { lastGutterFile = filePath app.RequestGitGutterForActiveFile() + app.updateCommentMarkers() } + // Update review progress in status bar + app.Status.ReviewProgress = app.ReviewStatusText() + if filePath != lastBlameFile || line != lastBlameLine { lastBlameFile = filePath lastBlameLine = line @@ -296,7 +300,13 @@ 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))) + // Auto-load review comments when PR is opened + app.LoadReviewCommentsForPR(v.Info.Owner, v.Info.Repo, v.Info.Number) } + case *ReviewCommentsFetchResult: + app.HandleReviewCommentsFetched(v) + case *reviewCommentPostResult: + app.HandleReviewCommentPosted(v) } redraw() } diff --git a/internal/app/review.go b/internal/app/review.go new file mode 100644 index 00000000..657bbdcc --- /dev/null +++ b/internal/app/review.go @@ -0,0 +1,381 @@ +package app + +import ( + "fmt" + "path/filepath" + + "github.com/eugenioenko/ttt/internal/git" + "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/ui" + + "github.com/gdamore/tcell/v2" +) + +// ReviewCommentsFetchResult is posted back to the event loop when comments are fetched. +type ReviewCommentsFetchResult struct { + Owner string + Repo string + Number int + Comments []github.PRComment + Err error +} + +// FetchReviewComments fetches PR review comments asynchronously. +func (a *App) FetchReviewComments(owner, repo string, number int) { + a.ReviewInbox.Loading = true + a.ReviewInbox.PROwner = owner + a.ReviewInbox.PRRepo = repo + a.ReviewInbox.PRNumber = number + a.StatusNotify(fmt.Sprintf("Fetching review comments for PR #%d...", number)) + + go func() { + comments, err := github.FetchPRComments(owner, repo, number) + a.Screen.PostEvent(tcell.NewEventInterrupt(&ReviewCommentsFetchResult{ + Owner: owner, + Repo: repo, + Number: number, + Comments: comments, + Err: err, + })) + }() +} + +// HandleReviewCommentsFetched processes the result of a comment fetch. +func (a *App) HandleReviewCommentsFetched(result *ReviewCommentsFetchResult) { + a.ReviewInbox.Loading = false + + if result.Err != nil { + a.StatusError("Failed to fetch review comments: " + result.Err.Error()) + return + } + + // Find the workspace dir for auto-detection + dir := a.findRepoDir() + + // Load or create review state + state, err := github.LoadReviewState(dir) + if err != nil || state == nil || state.PRNumber != result.Number { + state = github.NewReviewState(result.Owner, result.Repo, result.Number) + } + + // Auto-detect addressed comments + if dir != "" { + addressed := github.DetectAddressed(dir, result.Comments) + for id := range addressed { + // Only upgrade open comments to addressed, don't downgrade verified/dismissed + if state.GetState(id) == github.StateOpen { + state.SetState(id, github.StateAddressed) + } + } + } + + // Populate inbox + a.ReviewInbox.SetComments(result.Comments, state) + + // Save state + if dir != "" { + state.Save(dir) + } + + // Update gutter markers for the active file + a.updateCommentMarkers() + + // Show inbox + a.Sidebar.SetActivePanel("inbox") + if !a.Sidebar.Visible { + a.ShowSidebar() + } + a.Root.SetFocus(a.ReviewInbox) + + total := a.ReviewInbox.TotalComments() + progress := a.ReviewInbox.ProgressText() + a.StatusNotify(fmt.Sprintf("Loaded %d review comments (%s)", total, progress)) + a.Sidebar.SetPanelDirty("inbox", total > 0) +} + +// ReviewNextUnresolved navigates to the next unresolved comment. +func (a *App) ReviewNextUnresolved() { + unresolved := a.ReviewInbox.UnresolvedComments() + if len(unresolved) == 0 { + a.StatusNotify("No unresolved comments") + return + } + + // Find the current position + currentFile := a.EditorGroup.ActiveFilePath() + currentLine := 0 + if a.EditorGroup.IsEditorActive() { + currentLine = a.EditorGroup.Editor.Cursor.Line + 1 // 1-based + } + + // Find the next comment after the current position + var target *ui.ReviewInboxItem + var wrapTarget *ui.ReviewInboxItem + + for i := range unresolved { + item := &unresolved[i] + if !item.Comment.IsInline { + continue + } + itemFile := a.resolveCommentPath(item.Comment.Path) + + if wrapTarget == nil { + wrapTarget = item + } + + if itemFile == currentFile && item.Comment.Line > currentLine { + target = item + break + } + if itemFile > currentFile { + target = item + break + } + } + + if target == nil { + target = wrapTarget // wrap around + } + + if target == nil { + a.StatusNotify("No unresolved inline comments") + return + } + + a.navigateToComment(target.Comment) +} + +// ReviewPrevUnresolved navigates to the previous unresolved comment. +func (a *App) ReviewPrevUnresolved() { + unresolved := a.ReviewInbox.UnresolvedComments() + if len(unresolved) == 0 { + a.StatusNotify("No unresolved comments") + return + } + + currentFile := a.EditorGroup.ActiveFilePath() + currentLine := 0 + if a.EditorGroup.IsEditorActive() { + currentLine = a.EditorGroup.Editor.Cursor.Line + 1 + } + + var target *ui.ReviewInboxItem + + for i := len(unresolved) - 1; i >= 0; i-- { + item := &unresolved[i] + if !item.Comment.IsInline { + continue + } + itemFile := a.resolveCommentPath(item.Comment.Path) + + if itemFile == currentFile && item.Comment.Line < currentLine { + target = item + break + } + if itemFile < currentFile { + target = item + break + } + } + + if target == nil { + // Wrap around to last + for i := len(unresolved) - 1; i >= 0; i-- { + if unresolved[i].Comment.IsInline { + target = &unresolved[i] + break + } + } + } + + if target == nil { + a.StatusNotify("No unresolved inline comments") + return + } + + a.navigateToComment(target.Comment) +} + +// ReviewMarkVerified marks the comment on the current line as verified. +func (a *App) ReviewMarkVerified() { + if !a.ReviewInbox.HasData() { + return + } + + // Check if there's a selected comment in the inbox + if sel := a.ReviewInbox.SelectedComment(); sel != nil { + a.setCommentState(sel.Comment.ID, github.StateVerified) + return + } +} + +// ReviewAddInlineComment opens a dialog to compose a comment for the current line. +func (a *App) ReviewAddInlineComment() { + if !a.ReviewInbox.HasData() || a.ReviewInbox.PRNumber == 0 { + a.StatusNotify("No active PR review") + return + } + + currentFile := a.EditorGroup.ActiveFilePath() + if currentFile == "" { + return + } + currentLine := 1 + if a.EditorGroup.IsEditorActive() { + currentLine = a.EditorGroup.Editor.Cursor.Line + 1 + } + + // Find the relative path from the repo root + dir := a.findRepoDir() + relPath := currentFile + if dir != "" { + if rel, err := filepath.Rel(dir, currentFile); err == nil { + relPath = rel + } + } + + a.ShowInputDialog( + fmt.Sprintf("Comment on %s:%d", filepath.Base(relPath), currentLine), + "Enter your comment...", + "", + func(body string) { + if body == "" { + return + } + // Find the head SHA from the Changes widget PR groups + commitID := a.findPRHeadSHA() + if commitID == "" { + a.StatusError("Could not determine PR head commit") + return + } + go func() { + err := github.AddPRInlineComment( + a.ReviewInbox.PROwner, + a.ReviewInbox.PRRepo, + a.ReviewInbox.PRNumber, + body, relPath, currentLine, commitID, + ) + a.Screen.PostEvent(tcell.NewEventInterrupt(&reviewCommentPostResult{err: err})) + }() + }, + ) +} + +type reviewCommentPostResult struct { + err error +} + +// ReviewRefreshComments re-fetches comments for the active PR. +func (a *App) ReviewRefreshComments() { + if a.ReviewInbox.PRNumber == 0 { + a.StatusNotify("No active PR review to refresh") + return + } + a.FetchReviewComments(a.ReviewInbox.PROwner, a.ReviewInbox.PRRepo, a.ReviewInbox.PRNumber) +} + +// HandleReviewCommentPosted handles the result of posting a comment. +func (a *App) HandleReviewCommentPosted(result *reviewCommentPostResult) { + if result.err != nil { + a.StatusError("Failed to post comment: " + result.err.Error()) + return + } + a.StatusNotify("Comment posted successfully") + // Refresh to pick up the new comment + a.ReviewRefreshComments() +} + +// setCommentState updates a comment's state and persists it. +func (a *App) setCommentState(commentID int, state github.CommentState) { + a.ReviewInbox.UpdateCommentState(commentID, state) + a.updateCommentMarkers() + + dir := a.findRepoDir() + if dir != "" && a.ReviewInbox.State != nil { + a.ReviewInbox.State.Save(dir) + } + + a.Sidebar.SetPanelDirty("inbox", a.ReviewInbox.TotalComments() > 0) +} + +// navigateToComment opens a file and jumps to the comment's line. +func (a *App) navigateToComment(c github.PRComment) { + path := a.resolveCommentPath(c.Path) + a.EditorGroup.OpenFile(path) + if c.Line > 0 { + a.EditorGroup.GoToLine(c.Line) + } + a.FocusEditorIfEnabled() +} + +// resolveCommentPath converts a relative PR path to an absolute path. +func (a *App) resolveCommentPath(relPath string) string { + dir := a.findRepoDir() + if dir == "" { + return relPath + } + absPath := filepath.Join(dir, relPath) + return absPath +} + +// findRepoDir finds the git repo root for the workspace. +func (a *App) findRepoDir() string { + paths := a.Workspace.Paths() + for _, p := range paths { + if root := git.RepoRoot(p); root != "" { + return root + } + } + if len(paths) > 0 { + return paths[0] + } + return "" +} + +// findPRHeadSHA finds the head SHA from PR groups in the Changes widget. +func (a *App) findPRHeadSHA() string { + for _, g := range a.Changes.Groups { + if g.IsPR { + return g.PRHeadSHA + } + } + return "" +} + +// updateCommentMarkers updates the gutter markers for the active editor tab. +func (a *App) updateCommentMarkers() { + if !a.ReviewInbox.HasData() { + return + } + filePath := a.EditorGroup.ActiveFilePath() + if filePath == "" { + a.EditorGroup.Editor.CommentMarkers = nil + return + } + + // Try to match the file path against comment paths + dir := a.findRepoDir() + relPath := filePath + if dir != "" { + if rel, err := filepath.Rel(dir, filePath); err == nil { + relPath = rel + } + } + + markers := a.ReviewInbox.CommentMarkersForFile(relPath) + a.EditorGroup.Editor.CommentMarkers = markers +} + +// ReviewStatusText returns the status bar text for the review state. +func (a *App) ReviewStatusText() string { + if !a.ReviewInbox.HasData() { + return "" + } + return a.ReviewInbox.ProgressText() +} + +// LoadReviewCommentsForPR loads review comments when a PR is opened. +// Should be called after a PR is loaded in the Changes widget. +func (a *App) LoadReviewCommentsForPR(owner, repo string, number int) { + a.FetchReviewComments(owner, repo, number) +} diff --git a/internal/app/theme.go b/internal/app/theme.go index 8179d904..6dd1394a 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -45,6 +45,7 @@ func BuildStyleMap(theme config.ThemeConfig) term.StyleMap { applyStyleDef(&m, term.StyleGutterAdded, theme.Diff.GutterAdded) applyStyleDef(&m, term.StyleGutterDeleted, theme.Diff.GutterDeleted) applyStyleDef(&m, term.StyleGutterModified, theme.Diff.GutterModified) + applyStyleDef(&m, term.StyleGutterComment, theme.Diff.GutterModified) // reuse modified color for comment markers applyStyleDef(&m, term.StyleActiveLine, theme.Editor.ActiveLine) applyStyleDef(&m, term.StyleScrollbar, config.StyleDef{Fg: theme.Scrollbar.Bg}) applyStyleDef(&m, term.StyleScrollbarThumb, config.StyleDef{Fg: theme.Scrollbar.Fg}) diff --git a/internal/app/widgets.go b/internal/app/widgets.go index f72b4346..51540241 100644 --- a/internal/app/widgets.go +++ b/internal/app/widgets.go @@ -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()...) + reviewInbox := ui.NewReviewInboxWidget() sidebar := ui.NewSidebarWidget() sidebar.AddPanel("explorer", "Explore", explorer) sidebar.AddPanel("search", "Find", search) sidebar.AddPanel("changes", "Changes", changes) + sidebar.AddPanel("inbox", "Inbox", reviewInbox) hasFolders := len(ws.Paths()) > 0 sidebar.Visible = hasFolders sidebar.Borders = borders @@ -186,6 +188,7 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work Explorer: explorer, Search: search, Changes: changes, + ReviewInbox: reviewInbox, MenuBar: menuBar, StatusBar: statusBar, Status: status, diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 3ec5bfea..9bec6e29 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -340,6 +340,8 @@ 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 v", Command: "sidebar.inbox"}, + {Key: "ctrl+k n", Command: "review.nextUnresolved"}, {Key: "ctrl+t", Command: "terminal.toggle"}, {Key: "alt+t", Command: "terminal.fullscreen"}, {Key: "f10", Command: "menu.file"}, diff --git a/internal/github/github.go b/internal/github/github.go index f258ae89..20ce957b 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os/exec" + "sort" "strconv" "strings" ) @@ -165,3 +166,140 @@ func SplitMultiFileDiff(unified string) map[string]string { flush() return result } + +type PRComment struct { + ID int + Body string + User string + CreatedAt string + UpdatedAt string + Path string // empty for general comments + Line int // 0 for general comments + IsInline bool + InReplyTo int // 0 if not a reply +} + +func parseReviewComments(data []byte) ([]PRComment, error) { + var raw []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Path string `json:"path"` + Line *int `json:"line"` + OriginalLine *int `json:"original_line"` + InReplyToID int `json:"in_reply_to_id"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse review comments: %w", err) + } + var comments []PRComment + for _, r := range raw { + line := 0 + if r.Line != nil { + line = *r.Line + } else if r.OriginalLine != nil { + line = *r.OriginalLine + } + comments = append(comments, PRComment{ + ID: r.ID, + Body: r.Body, + User: r.User.Login, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + Path: r.Path, + Line: line, + IsInline: true, + InReplyTo: r.InReplyToID, + }) + } + return comments, nil +} + +func parseIssueComments(data []byte) ([]PRComment, error) { + var raw []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse issue comments: %w", err) + } + var comments []PRComment + for _, r := range raw { + comments = append(comments, PRComment{ + ID: r.ID, + Body: r.Body, + User: r.User.Login, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + IsInline: false, + }) + } + return comments, nil +} + +func FetchPRComments(owner, repo string, number int) ([]PRComment, error) { + // Fetch inline review comments + reviewCmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number), + "--paginate") + reviewOut, err := reviewCmd.Output() + if err != nil { + return nil, fmt.Errorf("gh api pull comments failed: %w", err) + } + reviewComments, err := parseReviewComments(reviewOut) + if err != nil { + return nil, err + } + + // Fetch general issue comments + issueCmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), + "--paginate") + issueOut, err := issueCmd.Output() + if err != nil { + return nil, fmt.Errorf("gh api issue comments failed: %w", err) + } + issueComments, err := parseIssueComments(issueOut) + if err != nil { + return nil, err + } + + // Combine and sort by ID + all := append(reviewComments, issueComments...) + sort.Slice(all, func(i, j int) bool { + return all[i].ID < all[j].ID + }) + return all, nil +} + +func AddPRComment(owner, repo string, number int, body string) error { + cmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number), + "-f", "body="+body) + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("gh api add comment failed: %w", err) + } + return nil +} + +func AddPRInlineComment(owner, repo string, number int, body, path string, line int, commitID string) error { + cmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number), + "-f", "body="+body, + "-f", "path="+path, + "-F", "line="+strconv.Itoa(line), + "-f", "commit_id="+commitID) + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("gh api add inline comment failed: %w", err) + } + return nil +} diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 0d88bb30..422f2617 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -65,3 +65,207 @@ diff --git a/pkg/file2.go b/pkg/file2.go t.Error("missing pkg/file2.go") } } + +func TestParseReviewComments(t *testing.T) { + data := []byte(`[ + { + "id": 101, + "body": "This needs a nil check", + "user": {"login": "reviewer1"}, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T10:00:00Z", + "path": "main.go", + "line": 42, + "original_line": 40, + "in_reply_to_id": 0 + }, + { + "id": 103, + "body": "Good point, fixed", + "user": {"login": "author1"}, + "created_at": "2024-01-15T11:00:00Z", + "updated_at": "2024-01-15T11:00:00Z", + "path": "main.go", + "line": 42, + "original_line": 40, + "in_reply_to_id": 101 + }, + { + "id": 105, + "body": "Outdated comment", + "user": {"login": "reviewer2"}, + "created_at": "2024-01-15T12:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "path": "utils.go", + "line": null, + "original_line": 10, + "in_reply_to_id": 0 + } + ]`) + + comments, err := parseReviewComments(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 3 { + t.Fatalf("expected 3 comments, got %d", len(comments)) + } + + // First comment: inline review comment with line set + c := comments[0] + if c.ID != 101 { + t.Errorf("expected ID 101, got %d", c.ID) + } + if c.Body != "This needs a nil check" { + t.Errorf("unexpected body: %s", c.Body) + } + if c.User != "reviewer1" { + t.Errorf("expected user reviewer1, got %s", c.User) + } + if c.CreatedAt != "2024-01-15T10:00:00Z" { + t.Errorf("unexpected created_at: %s", c.CreatedAt) + } + if c.Path != "main.go" { + t.Errorf("expected path main.go, got %s", c.Path) + } + if c.Line != 42 { + t.Errorf("expected line 42, got %d", c.Line) + } + if !c.IsInline { + t.Error("expected IsInline to be true") + } + if c.InReplyTo != 0 { + t.Errorf("expected InReplyTo 0, got %d", c.InReplyTo) + } + + // Reply comment + c = comments[1] + if c.ID != 103 { + t.Errorf("expected ID 103, got %d", c.ID) + } + if c.InReplyTo != 101 { + t.Errorf("expected InReplyTo 101, got %d", c.InReplyTo) + } + + // Comment with null line (should fallback to original_line) + c = comments[2] + if c.ID != 105 { + t.Errorf("expected ID 105, got %d", c.ID) + } + if c.Line != 10 { + t.Errorf("expected line 10 (from original_line fallback), got %d", c.Line) + } + if c.Path != "utils.go" { + t.Errorf("expected path utils.go, got %s", c.Path) + } +} + +func TestParseReviewCommentsEmpty(t *testing.T) { + data := []byte(`[]`) + comments, err := parseReviewComments(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 0 { + t.Fatalf("expected 0 comments, got %d", len(comments)) + } +} + +func TestParseReviewCommentsInvalidJSON(t *testing.T) { + data := []byte(`not json`) + _, err := parseReviewComments(data) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParseIssueComments(t *testing.T) { + data := []byte(`[ + { + "id": 200, + "body": "Looks good overall", + "user": {"login": "reviewer1"}, + "created_at": "2024-01-15T09:00:00Z", + "updated_at": "2024-01-15T09:30:00Z" + }, + { + "id": 202, + "body": "Please add tests", + "user": {"login": "reviewer2"}, + "created_at": "2024-01-15T13:00:00Z", + "updated_at": "2024-01-15T13:00:00Z" + } + ]`) + + comments, err := parseIssueComments(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(comments)) + } + + // First comment: general issue comment + c := comments[0] + if c.ID != 200 { + t.Errorf("expected ID 200, got %d", c.ID) + } + if c.Body != "Looks good overall" { + t.Errorf("unexpected body: %s", c.Body) + } + if c.User != "reviewer1" { + t.Errorf("expected user reviewer1, got %s", c.User) + } + if c.CreatedAt != "2024-01-15T09:00:00Z" { + t.Errorf("unexpected created_at: %s", c.CreatedAt) + } + if c.UpdatedAt != "2024-01-15T09:30:00Z" { + t.Errorf("unexpected updated_at: %s", c.UpdatedAt) + } + if c.Path != "" { + t.Errorf("expected empty path for general comment, got %s", c.Path) + } + if c.Line != 0 { + t.Errorf("expected line 0 for general comment, got %d", c.Line) + } + if c.IsInline { + t.Error("expected IsInline to be false for general comment") + } + if c.InReplyTo != 0 { + t.Errorf("expected InReplyTo 0, got %d", c.InReplyTo) + } + + // Second comment + c = comments[1] + if c.ID != 202 { + t.Errorf("expected ID 202, got %d", c.ID) + } + if c.Body != "Please add tests" { + t.Errorf("unexpected body: %s", c.Body) + } + if c.User != "reviewer2" { + t.Errorf("expected user reviewer2, got %s", c.User) + } + if c.IsInline { + t.Error("expected IsInline to be false") + } +} + +func TestParseIssueCommentsEmpty(t *testing.T) { + data := []byte(`[]`) + comments, err := parseIssueComments(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 0 { + t.Fatalf("expected 0 comments, got %d", len(comments)) + } +} + +func TestParseIssueCommentsInvalidJSON(t *testing.T) { + data := []byte(`{broken}`) + _, err := parseIssueComments(data) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} diff --git a/internal/github/review_state.go b/internal/github/review_state.go new file mode 100644 index 00000000..2cfd0904 --- /dev/null +++ b/internal/github/review_state.go @@ -0,0 +1,132 @@ +package github + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// CommentState represents the review state of a comment thread +type CommentState int + +const ( + StateOpen CommentState = iota // Unresolved + StateAddressed // Code changed since comment + StateVerified // Reviewer confirmed fix + StateDismissed // Won't fix +) + +func (s CommentState) String() string { + switch s { + case StateOpen: + return "open" + case StateAddressed: + return "addressed" + case StateVerified: + return "verified" + case StateDismissed: + return "dismissed" + default: + return "open" + } +} + +// ReviewState holds persistent state for a PR review session +type ReviewState struct { + PRNumber int `json:"pr_number"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Comments map[int]CommentState `json:"comments"` // comment ID -> state +} + +const reviewStateFile = ".ttt-review-state.json" + +// NewReviewState creates an empty review state for a PR. +func NewReviewState(owner, repo string, number int) *ReviewState { + return &ReviewState{ + PRNumber: number, + Owner: owner, + Repo: repo, + Comments: make(map[int]CommentState), + } +} + +// SetState sets the review state for a comment. +func (rs *ReviewState) SetState(commentID int, state CommentState) { + rs.Comments[commentID] = state +} + +// GetState returns the review state for a comment, defaulting to StateOpen. +func (rs *ReviewState) GetState(commentID int) CommentState { + if s, ok := rs.Comments[commentID]; ok { + return s + } + return StateOpen +} + +// Save writes the review state to {dir}/.ttt-review-state.json. +func (rs *ReviewState) Save(dir string) error { + data, err := json.MarshalIndent(rs, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, reviewStateFile), data, 0644) +} + +// LoadReviewState reads review state from {dir}/.ttt-review-state.json. +func LoadReviewState(dir string) (*ReviewState, error) { + data, err := os.ReadFile(filepath.Join(dir, reviewStateFile)) + if err != nil { + return nil, err + } + var rs ReviewState + if err := json.Unmarshal(data, &rs); err != nil { + return nil, err + } + if rs.Comments == nil { + rs.Comments = make(map[int]CommentState) + } + return &rs, nil +} + +// CountByState returns the number of comments in each state. +func (rs *ReviewState) CountByState() (open, addressed, verified, dismissed int) { + for _, s := range rs.Comments { + switch s { + case StateOpen: + open++ + case StateAddressed: + addressed++ + case StateVerified: + verified++ + case StateDismissed: + dismissed++ + } + } + return +} + +// DetectAddressed checks whether the file referenced by each inline comment +// has been modified after the comment was created. It runs +// git log --since="" --oneline -- for each comment and +// returns a map of comment IDs that are addressed (file was changed). +func DetectAddressed(dir string, comments []PRComment) map[int]bool { + result := make(map[int]bool) + for _, c := range comments { + if !c.IsInline || c.Path == "" { + continue + } + cmd := exec.Command("git", "-C", dir, + "log", "--since="+c.CreatedAt, "--oneline", "--", c.Path) + out, err := cmd.Output() + if err != nil { + continue + } + if strings.TrimSpace(string(out)) != "" { + result[c.ID] = true + } + } + return result +} diff --git a/internal/github/review_state_test.go b/internal/github/review_state_test.go new file mode 100644 index 00000000..c39c31f7 --- /dev/null +++ b/internal/github/review_state_test.go @@ -0,0 +1,158 @@ +package github + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCommentStateString(t *testing.T) { + tests := []struct { + state CommentState + want string + }{ + {StateOpen, "open"}, + {StateAddressed, "addressed"}, + {StateVerified, "verified"}, + {StateDismissed, "dismissed"}, + {CommentState(99), "open"}, // unknown defaults to "open" + } + for _, tt := range tests { + got := tt.state.String() + if got != tt.want { + t.Errorf("CommentState(%d).String() = %q, want %q", int(tt.state), got, tt.want) + } + } +} + +func TestNewReviewState(t *testing.T) { + rs := NewReviewState("octocat", "hello-world", 42) + if rs.Owner != "octocat" { + t.Errorf("Owner = %q, want %q", rs.Owner, "octocat") + } + if rs.Repo != "hello-world" { + t.Errorf("Repo = %q, want %q", rs.Repo, "hello-world") + } + if rs.PRNumber != 42 { + t.Errorf("PRNumber = %d, want %d", rs.PRNumber, 42) + } + if rs.Comments == nil { + t.Fatal("Comments map should be initialized, got nil") + } + if len(rs.Comments) != 0 { + t.Errorf("Comments should be empty, got %d entries", len(rs.Comments)) + } +} + +func TestSetGetState(t *testing.T) { + rs := NewReviewState("owner", "repo", 1) + + // Default state for unknown comment is StateOpen + if got := rs.GetState(999); got != StateOpen { + t.Errorf("GetState(unknown) = %v, want StateOpen", got) + } + + // Set and get each state + rs.SetState(1, StateAddressed) + if got := rs.GetState(1); got != StateAddressed { + t.Errorf("GetState(1) = %v, want StateAddressed", got) + } + + rs.SetState(2, StateVerified) + if got := rs.GetState(2); got != StateVerified { + t.Errorf("GetState(2) = %v, want StateVerified", got) + } + + rs.SetState(3, StateDismissed) + if got := rs.GetState(3); got != StateDismissed { + t.Errorf("GetState(3) = %v, want StateDismissed", got) + } + + // Overwrite existing state + rs.SetState(1, StateVerified) + if got := rs.GetState(1); got != StateVerified { + t.Errorf("GetState(1) after overwrite = %v, want StateVerified", got) + } +} + +func TestCountByState(t *testing.T) { + rs := NewReviewState("owner", "repo", 1) + + // Empty state + open, addressed, verified, dismissed := rs.CountByState() + if open != 0 || addressed != 0 || verified != 0 || dismissed != 0 { + t.Errorf("empty CountByState = (%d,%d,%d,%d), want (0,0,0,0)", + open, addressed, verified, dismissed) + } + + rs.SetState(1, StateOpen) + rs.SetState(2, StateOpen) + rs.SetState(3, StateAddressed) + rs.SetState(4, StateVerified) + rs.SetState(5, StateDismissed) + rs.SetState(6, StateDismissed) + + open, addressed, verified, dismissed = rs.CountByState() + if open != 2 { + t.Errorf("open = %d, want 2", open) + } + if addressed != 1 { + t.Errorf("addressed = %d, want 1", addressed) + } + if verified != 1 { + t.Errorf("verified = %d, want 1", verified) + } + if dismissed != 2 { + t.Errorf("dismissed = %d, want 2", dismissed) + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + + rs := NewReviewState("octocat", "hello-world", 42) + rs.SetState(10, StateAddressed) + rs.SetState(20, StateVerified) + rs.SetState(30, StateDismissed) + + if err := rs.Save(dir); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Verify file exists + path := filepath.Join(dir, ".ttt-review-state.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("state file not found: %v", err) + } + + loaded, err := LoadReviewState(dir) + if err != nil { + t.Fatalf("LoadReviewState failed: %v", err) + } + + if loaded.Owner != rs.Owner { + t.Errorf("loaded Owner = %q, want %q", loaded.Owner, rs.Owner) + } + if loaded.Repo != rs.Repo { + t.Errorf("loaded Repo = %q, want %q", loaded.Repo, rs.Repo) + } + if loaded.PRNumber != rs.PRNumber { + t.Errorf("loaded PRNumber = %d, want %d", loaded.PRNumber, rs.PRNumber) + } + if len(loaded.Comments) != len(rs.Comments) { + t.Fatalf("loaded Comments length = %d, want %d", len(loaded.Comments), len(rs.Comments)) + } + for id, state := range rs.Comments { + if loaded.Comments[id] != state { + t.Errorf("loaded Comments[%d] = %v, want %v", id, loaded.Comments[id], state) + } + } +} + +func TestLoadReviewStateNotFound(t *testing.T) { + dir := t.TempDir() + _, err := LoadReviewState(dir) + if err == nil { + t.Fatal("LoadReviewState should return error for missing file") + } +} diff --git a/internal/term/screen.go b/internal/term/screen.go index d2d05e49..df1968cf 100644 --- a/internal/term/screen.go +++ b/internal/term/screen.go @@ -58,6 +58,7 @@ const ( StyleGutterAdded StyleGutterModified StyleGutterDeleted + StyleGutterComment ) // DirectColor holds an RGBA color for terminal emulator output. diff --git a/internal/term/screen_test.go b/internal/term/screen_test.go index cfa8f8f5..b189b2b2 100644 --- a/internal/term/screen_test.go +++ b/internal/term/screen_test.go @@ -227,6 +227,7 @@ func TestStyleConstants(t *testing.T) { "StyleGutterAdded": StyleGutterAdded, "StyleGutterModified": StyleGutterModified, "StyleGutterDeleted": StyleGutterDeleted, + "StyleGutterComment": StyleGutterComment, } seen := make(map[Style]string) diff --git a/internal/ui/editor_widget.go b/internal/ui/editor_widget.go index b2664423..16ce33f9 100644 --- a/internal/ui/editor_widget.go +++ b/internal/ui/editor_widget.go @@ -61,6 +61,7 @@ type EditorPaneWidget struct { searchByLine map[int][]int diagByLine map[int][]int LineChanges []diff.LineChangeKind + CommentMarkers map[int]CommentMarkerInfo bracketColorCache bracketColorMap bracketColorDirty bool wrapMap []wrapEntry @@ -320,9 +321,11 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) { } } } + hasGutterChange := false if lineIdx < totalLines && lineIdx < len(e.LineChanges) && !isWrapContinuation { change := e.LineChanges[lineIdx] if change != diff.LineUnchanged { + hasGutterChange = true var ch rune var style term.Style switch change { @@ -339,6 +342,11 @@ func (e *EditorPaneWidget) Render(surface *RenderSurface) { surface.SetCell(0, y, term.Cell{Ch: ch, Style: style}) } } + if !hasGutterChange && lineIdx < totalLines && !isWrapContinuation && len(e.CommentMarkers) > 0 { + if _, ok := e.CommentMarkers[lineIdx]; ok { + surface.SetCell(0, y, term.Cell{Ch: '◆', Style: term.StyleGutterComment}) + } + } } if lineIdx < totalLines { diff --git a/internal/ui/review_inbox_widget.go b/internal/ui/review_inbox_widget.go new file mode 100644 index 00000000..f30aac44 --- /dev/null +++ b/internal/ui/review_inbox_widget.go @@ -0,0 +1,644 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/term" + + "github.com/gdamore/tcell/v2" +) + +// ReviewInboxItem represents a single item in the review inbox list. +type ReviewInboxItem struct { + Comment github.PRComment + State github.CommentState +} + +// ReviewFileGroup groups inline comments by file path. +type ReviewFileGroup struct { + Path string + Comments []ReviewInboxItem + Expanded bool + OpenCount int // number of open/addressed comments +} + +// inboxItemKind identifies the type of a flattened inbox row. +type inboxItemKind int + +const ( + inboxItemFileHeader inboxItemKind = iota + inboxItemComment + inboxItemGeneralHeader + inboxItemGeneralComment + inboxItemBorder +) + +// inboxItem is a flattened row for rendering/selection. +type inboxItem struct { + kind inboxItemKind + groupIndex int // index into FileGroups or -1 + itemIndex int // index into group's Comments or GeneralComments +} + +// ReviewInboxWidget is a sidebar panel for PR review comments. +type ReviewInboxWidget struct { + BaseWidget + SelectableList + + // Data + FileGroups []ReviewFileGroup + GeneralComments []ReviewInboxItem + items []inboxItem + Loading bool + + // PR info + PROwner string + PRRepo string + PRNumber int + + // State persistence + State *github.ReviewState + + // Callbacks + OnOpenFile func(path string, line int) // navigate to file:line + OnMarkVerified func(commentID int) + OnMarkDismissed func(commentID int) + OnReopen func(commentID int) + OnAddReply func(comment github.PRComment) + OnAddComment func() + OnRefresh func() +} + +// NewReviewInboxWidget creates a new review inbox widget. +func NewReviewInboxWidget() *ReviewInboxWidget { + return &ReviewInboxWidget{} +} + +func (r *ReviewInboxWidget) Focusable() bool { return true } + +// SetComments populates the inbox with PR comments and review state. +func (r *ReviewInboxWidget) SetComments(comments []github.PRComment, state *github.ReviewState) { + r.State = state + + // Group inline comments by file path + fileMap := make(map[string][]ReviewInboxItem) + var fileOrder []string + r.GeneralComments = nil + + for _, c := range comments { + itemState := state.GetState(c.ID) + item := ReviewInboxItem{Comment: c, State: itemState} + + if c.IsInline && c.Path != "" { + if _, exists := fileMap[c.Path]; !exists { + fileOrder = append(fileOrder, c.Path) + } + fileMap[c.Path] = append(fileMap[c.Path], item) + } else { + r.GeneralComments = append(r.GeneralComments, item) + } + } + + r.FileGroups = nil + for _, path := range fileOrder { + items := fileMap[path] + openCount := 0 + for _, item := range items { + if item.State == github.StateOpen || item.State == github.StateAddressed { + openCount++ + } + } + r.FileGroups = append(r.FileGroups, ReviewFileGroup{ + Path: path, + Comments: items, + Expanded: true, + OpenCount: openCount, + }) + } + + r.buildItems() + r.ClampSelected(len(r.items)) +} + +// UpdateCommentState updates the state of a specific comment. +func (r *ReviewInboxWidget) UpdateCommentState(commentID int, state github.CommentState) { + if r.State != nil { + r.State.SetState(commentID, state) + } + for gi := range r.FileGroups { + for ci := range r.FileGroups[gi].Comments { + if r.FileGroups[gi].Comments[ci].Comment.ID == commentID { + r.FileGroups[gi].Comments[ci].State = state + // Recalculate open count + openCount := 0 + for _, item := range r.FileGroups[gi].Comments { + if item.State == github.StateOpen || item.State == github.StateAddressed { + openCount++ + } + } + r.FileGroups[gi].OpenCount = openCount + return + } + } + } + for ci := range r.GeneralComments { + if r.GeneralComments[ci].Comment.ID == commentID { + r.GeneralComments[ci].State = state + return + } + } +} + +// ProgressText returns a string like "4/7 addressed" for the status bar. +func (r *ReviewInboxWidget) ProgressText() string { + total := 0 + resolved := 0 + for _, g := range r.FileGroups { + for _, item := range g.Comments { + total++ + if item.State == github.StateVerified || item.State == github.StateDismissed { + resolved++ + } + } + } + for _, item := range r.GeneralComments { + total++ + if item.State == github.StateVerified || item.State == github.StateDismissed { + resolved++ + } + } + if total == 0 { + return "" + } + return fmt.Sprintf("%d/%d resolved", resolved, total) +} + +// TotalComments returns the total number of comments. +func (r *ReviewInboxWidget) TotalComments() int { + total := 0 + for _, g := range r.FileGroups { + total += len(g.Comments) + } + total += len(r.GeneralComments) + return total +} + +// UnresolvedComments returns all unresolved inline comments sorted by file and line. +func (r *ReviewInboxWidget) UnresolvedComments() []ReviewInboxItem { + var result []ReviewInboxItem + for _, g := range r.FileGroups { + for _, item := range g.Comments { + if item.State == github.StateOpen || item.State == github.StateAddressed { + result = append(result, item) + } + } + } + return result +} + +// SelectedComment returns the currently selected comment, if any. +func (r *ReviewInboxWidget) SelectedComment() *ReviewInboxItem { + if r.Selected < 0 || r.Selected >= len(r.items) { + return nil + } + item := r.items[r.Selected] + switch item.kind { + case inboxItemComment: + if item.groupIndex >= 0 && item.groupIndex < len(r.FileGroups) { + g := &r.FileGroups[item.groupIndex] + if item.itemIndex >= 0 && item.itemIndex < len(g.Comments) { + return &g.Comments[item.itemIndex] + } + } + case inboxItemGeneralComment: + if item.itemIndex >= 0 && item.itemIndex < len(r.GeneralComments) { + return &r.GeneralComments[item.itemIndex] + } + } + return nil +} + +// HasData returns true if there are any comments loaded. +func (r *ReviewInboxWidget) HasData() bool { + return len(r.FileGroups) > 0 || len(r.GeneralComments) > 0 +} + +// CommentMarkersForFile returns comment markers for the given file path. +// Returns a map of line number -> CommentMarkerInfo. +func (r *ReviewInboxWidget) CommentMarkersForFile(filePath string) map[int]CommentMarkerInfo { + markers := make(map[int]CommentMarkerInfo) + for _, g := range r.FileGroups { + if g.Path != filePath && !strings.HasSuffix(filePath, "/"+g.Path) { + continue + } + for _, item := range g.Comments { + if item.Comment.Line <= 0 { + continue + } + line := item.Comment.Line - 1 // 0-based + existing, ok := markers[line] + if !ok || commentStatePriority(item.State) > commentStatePriority(existing.State) { + markers[line] = CommentMarkerInfo{ + State: item.State, + Count: 1, + Preview: truncate(item.Comment.Body, 40), + } + } else if ok { + info := markers[line] + info.Count++ + markers[line] = info + } + } + } + return markers +} + +// CommentMarkerInfo holds info about comment markers for a specific line. +type CommentMarkerInfo struct { + State github.CommentState + Count int + Preview string +} + +func commentStatePriority(s github.CommentState) int { + switch s { + case github.StateOpen: + return 3 + case github.StateAddressed: + return 2 + case github.StateVerified: + return 1 + case github.StateDismissed: + return 0 + } + return 0 +} + +func truncate(s string, maxLen int) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", "") + runes := []rune(s) + if len(runes) > maxLen { + return string(runes[:maxLen-2]) + ".." + } + return s +} + +func (r *ReviewInboxWidget) buildItems() { + r.items = nil + + for gi, g := range r.FileGroups { + r.items = append(r.items, inboxItem{kind: inboxItemFileHeader, groupIndex: gi}) + if g.Expanded { + for ci := range g.Comments { + r.items = append(r.items, inboxItem{kind: inboxItemComment, groupIndex: gi, itemIndex: ci}) + } + } + } + + if len(r.GeneralComments) > 0 { + r.items = append(r.items, inboxItem{kind: inboxItemBorder}) + r.items = append(r.items, inboxItem{kind: inboxItemGeneralHeader}) + for ci := range r.GeneralComments { + r.items = append(r.items, inboxItem{kind: inboxItemGeneralComment, itemIndex: ci}) + } + } +} + +// Render draws the review inbox widget. +func (r *ReviewInboxWidget) Render(surface *RenderSurface) { + w, h := surface.Size() + surface.Fill(term.Cell{Ch: ' '}) + + if !r.HasData() { + msg := "No review comments" + if r.Loading { + msg = "Loading comments..." + } + for i, ch := range msg { + if i+1 < w { + surface.SetCell(i+1, 0, term.Cell{Ch: ch, Style: term.StyleDefault}) + } + } + return + } + + if h <= 0 { + return + } + + // Show progress in the first row + progress := r.ProgressText() + if progress != "" { + x := w - len([]rune(progress)) - 1 + if x < 0 { + x = 0 + } + for i, ch := range progress { + if x+i < w { + surface.SetCell(x+i, 0, term.Cell{Ch: ch, Style: term.StyleMuted}) + } + } + } + + r.EnsureVisible(h) + + for i := 0; i < h; i++ { + idx := r.ScrollTop + i + if idx >= len(r.items) { + break + } + item := r.items[idx] + y := i + + style := term.StyleDefault + if idx == r.Selected { + style = term.StyleSidebarSelected + } + + for x := 0; x < w; x++ { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + } + + switch item.kind { + case inboxItemFileHeader: + r.renderFileHeader(surface, y, w, style, item.groupIndex) + case inboxItemComment: + r.renderComment(surface, y, w, style, item.groupIndex, item.itemIndex) + case inboxItemGeneralHeader: + r.renderGeneralHeader(surface, y, w, style) + case inboxItemGeneralComment: + r.renderGeneralComment(surface, y, w, style, item.itemIndex) + case inboxItemBorder: + for x := 0; x < w; x++ { + surface.SetCell(x, y, term.Cell{Ch: '─', Style: term.StyleBorder}) + } + } + } +} + +func (r *ReviewInboxWidget) renderFileHeader(surface *RenderSurface, y, w int, style term.Style, gi int) { + g := r.FileGroups[gi] + x := 0 + + // Chevron + chevron := '▶' + if g.Expanded { + chevron = '▼' + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: chevron, Style: style}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // File name (base name only) + name := filepath.Base(g.Path) + countStr := fmt.Sprintf(" %d/%d", g.OpenCount, len(g.Comments)) + maxNameW := w - x - len([]rune(countStr)) - 1 + for _, ch := range name { + if x >= maxNameW+2 { // +2 for chevron+space + break + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ch, Style: style}) + x++ + } + } + + // Open count at the right + cx := w - len([]rune(countStr)) + if cx < x { + cx = x + } + countStyle := term.StyleMuted + if g.OpenCount > 0 { + countStyle = term.StyleWarning + } + for _, ch := range countStr { + if cx < w { + surface.SetCell(cx, y, term.Cell{Ch: ch, Style: countStyle}) + cx++ + } + } +} + +func (r *ReviewInboxWidget) renderComment(surface *RenderSurface, y, w int, style term.Style, gi, ci int) { + g := r.FileGroups[gi] + item := g.Comments[ci] + x := 2 // indent + + // State indicator + indicator, indicatorStyle := stateIndicator(item.State) + if x < w { + surface.SetCell(x, y, term.Cell{Ch: indicator, Style: indicatorStyle}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // Line number + lineStr := fmt.Sprintf("L:%d", item.Comment.Line) + for _, ch := range lineStr { + if x >= w-1 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: term.StyleMuted}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // @user + userStr := "@" + item.Comment.User + ":" + for _, ch := range userStr { + if x >= w-1 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: term.StyleMuted}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // Body preview + body := truncate(item.Comment.Body, w-x) + for _, ch := range body { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: style}) + x++ + } +} + +func (r *ReviewInboxWidget) renderGeneralHeader(surface *RenderSurface, y, w int, style term.Style) { + label := "── General " + x := 0 + for _, ch := range label { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: term.StyleMuted}) + x++ + } + for x < w { + surface.SetCell(x, y, term.Cell{Ch: '─', Style: term.StyleMuted}) + x++ + } +} + +func (r *ReviewInboxWidget) renderGeneralComment(surface *RenderSurface, y, w int, style term.Style, ci int) { + item := r.GeneralComments[ci] + x := 2 // indent + + // State indicator + indicator, indicatorStyle := stateIndicator(item.State) + if x < w { + surface.SetCell(x, y, term.Cell{Ch: indicator, Style: indicatorStyle}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // @user + userStr := "@" + item.Comment.User + ":" + for _, ch := range userStr { + if x >= w-1 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: term.StyleMuted}) + x++ + } + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // Body preview + body := truncate(item.Comment.Body, w-x) + for _, ch := range body { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: style}) + x++ + } +} + +func stateIndicator(state github.CommentState) (rune, term.Style) { + switch state { + case github.StateOpen: + return '●', term.StyleDanger // ● (filled circle) - red + case github.StateAddressed: + return '~', term.StyleWarning // ~ - yellow + case github.StateVerified: + return '✓', term.StyleSuccess // ✓ - green + case github.StateDismissed: + return '✗', term.StyleMuted // ✗ - dimmed + } + return '●', term.StyleDanger +} + +// HandleEvent handles keyboard and mouse events for the review inbox. +func (r *ReviewInboxWidget) HandleEvent(ev tcell.Event) EventResult { + if !r.HasData() { + return EventIgnored + } + + // Handle keyboard shortcuts first + if kev, ok := ev.(*tcell.EventKey); ok { + switch kev.Key() { + case tcell.KeyRune: + switch kev.Rune() { + case 'v': // mark verified + if sel := r.SelectedComment(); sel != nil { + if r.OnMarkVerified != nil { + r.OnMarkVerified(sel.Comment.ID) + } + return EventConsumed + } + case 'd': // dismiss + if sel := r.SelectedComment(); sel != nil { + if r.OnMarkDismissed != nil { + r.OnMarkDismissed(sel.Comment.ID) + } + return EventConsumed + } + case 'r': // reopen or refresh + if sel := r.SelectedComment(); sel != nil { + if sel.State == github.StateVerified || sel.State == github.StateDismissed { + if r.OnReopen != nil { + r.OnReopen(sel.Comment.ID) + } + return EventConsumed + } + } + // If no comment selected or it's not closed, refresh + if r.OnRefresh != nil { + r.OnRefresh() + return EventConsumed + } + case 'a': // add reply + if sel := r.SelectedComment(); sel != nil { + if r.OnAddReply != nil { + r.OnAddReply(sel.Comment) + } + return EventConsumed + } + } + } + } + + rect := r.GetRect() + lr := r.SelectableList.HandleListEvent(ev, rect, len(r.items)) + if lr.Action == ListActionActivate { + r.handleActivate() + return EventConsumed + } + return lr.Result +} + +func (r *ReviewInboxWidget) handleActivate() { + if r.Selected < 0 || r.Selected >= len(r.items) { + return + } + item := r.items[r.Selected] + + switch item.kind { + case inboxItemFileHeader: + // Toggle expansion + if item.groupIndex >= 0 && item.groupIndex < len(r.FileGroups) { + r.FileGroups[item.groupIndex].Expanded = !r.FileGroups[item.groupIndex].Expanded + r.buildItems() + r.ClampSelected(len(r.items)) + } + case inboxItemComment: + // Navigate to file:line + if item.groupIndex >= 0 && item.groupIndex < len(r.FileGroups) { + g := r.FileGroups[item.groupIndex] + if item.itemIndex >= 0 && item.itemIndex < len(g.Comments) { + c := g.Comments[item.itemIndex] + if r.OnOpenFile != nil { + r.OnOpenFile(c.Comment.Path, c.Comment.Line) + } + } + } + case inboxItemGeneralComment: + // Could show full comment in future + } +} diff --git a/internal/ui/review_inbox_widget_test.go b/internal/ui/review_inbox_widget_test.go new file mode 100644 index 00000000..a3dd2ef9 --- /dev/null +++ b/internal/ui/review_inbox_widget_test.go @@ -0,0 +1,260 @@ +package ui + +import ( + "testing" + + "github.com/eugenioenko/ttt/internal/github" +) + +func makeTestComments() []github.PRComment { + return []github.PRComment{ + {ID: 1, Body: "Fix this bug", User: "alice", Path: "main.go", Line: 42, IsInline: true, CreatedAt: "2024-01-01T00:00:00Z"}, + {ID: 2, Body: "Why is this needed?", User: "bob", Path: "main.go", Line: 78, IsInline: true, CreatedAt: "2024-01-01T01:00:00Z"}, + {ID: 3, Body: "Nit: typo", User: "alice", Path: "main.go", Line: 95, IsInline: true, CreatedAt: "2024-01-01T02:00:00Z"}, + {ID: 4, Body: "Bug in utils", User: "carol", Path: "util.go", Line: 12, IsInline: true, CreatedAt: "2024-01-01T03:00:00Z"}, + {ID: 5, Body: "Typo fix", User: "dave", Path: "util.go", Line: 30, IsInline: true, CreatedAt: "2024-01-01T04:00:00Z"}, + {ID: 6, Body: "LGTM with minor changes", User: "alice", IsInline: false, CreatedAt: "2024-01-01T05:00:00Z"}, + {ID: 7, Body: "Ship it", User: "bob", IsInline: false, CreatedAt: "2024-01-01T06:00:00Z"}, + } +} + +func TestReviewInboxSetComments(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + if len(w.FileGroups) != 2 { + t.Fatalf("expected 2 file groups, got %d", len(w.FileGroups)) + } + + if w.FileGroups[0].Path != "main.go" { + t.Errorf("expected first group to be main.go, got %s", w.FileGroups[0].Path) + } + if len(w.FileGroups[0].Comments) != 3 { + t.Errorf("expected 3 comments in main.go, got %d", len(w.FileGroups[0].Comments)) + } + + if w.FileGroups[1].Path != "util.go" { + t.Errorf("expected second group to be util.go, got %s", w.FileGroups[1].Path) + } + if len(w.FileGroups[1].Comments) != 2 { + t.Errorf("expected 2 comments in util.go, got %d", len(w.FileGroups[1].Comments)) + } + + if len(w.GeneralComments) != 2 { + t.Errorf("expected 2 general comments, got %d", len(w.GeneralComments)) + } + + if w.TotalComments() != 7 { + t.Errorf("expected 7 total comments, got %d", w.TotalComments()) + } +} + +func TestReviewInboxStateTransitions(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + // All should be open initially + progress := w.ProgressText() + if progress != "0/7 resolved" { + t.Errorf("expected '0/7 resolved', got %q", progress) + } + + // Mark comment 1 as verified + w.UpdateCommentState(1, github.StateVerified) + progress = w.ProgressText() + if progress != "1/7 resolved" { + t.Errorf("expected '1/7 resolved', got %q", progress) + } + + // Mark comment 3 as dismissed + w.UpdateCommentState(3, github.StateDismissed) + progress = w.ProgressText() + if progress != "2/7 resolved" { + t.Errorf("expected '2/7 resolved', got %q", progress) + } + + // Check that state is persisted in the ReviewState object + if state.GetState(1) != github.StateVerified { + t.Error("expected comment 1 state to be verified in ReviewState") + } + if state.GetState(3) != github.StateDismissed { + t.Error("expected comment 3 state to be dismissed in ReviewState") + } +} + +func TestReviewInboxUnresolvedComments(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + unresolved := w.UnresolvedComments() + if len(unresolved) != 5 { + t.Fatalf("expected 5 unresolved inline comments, got %d", len(unresolved)) + } + + // Mark 2 as verified + w.UpdateCommentState(1, github.StateVerified) + w.UpdateCommentState(2, github.StateDismissed) + + // Re-query (need to re-set comments since UpdateCommentState changes state in-place) + unresolved = w.UnresolvedComments() + if len(unresolved) != 3 { + t.Fatalf("expected 3 unresolved inline comments after marking 2, got %d", len(unresolved)) + } +} + +func TestReviewInboxOpenCount(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + state.SetState(3, github.StateVerified) // pre-set one as verified + + comments := makeTestComments() + w.SetComments(comments, state) + + // main.go: 3 comments, 1 verified = 2 open + if w.FileGroups[0].OpenCount != 2 { + t.Errorf("expected main.go open count = 2, got %d", w.FileGroups[0].OpenCount) + } + + // util.go: 2 comments, all open + if w.FileGroups[1].OpenCount != 2 { + t.Errorf("expected util.go open count = 2, got %d", w.FileGroups[1].OpenCount) + } +} + +func TestReviewInboxSelectedComment(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + // First item is a file header (main.go), not a comment + w.Selected = 0 + if sel := w.SelectedComment(); sel != nil { + t.Error("expected no selected comment on file header") + } + + // Second item is the first comment + w.Selected = 1 + sel := w.SelectedComment() + if sel == nil { + t.Fatal("expected a selected comment at index 1") + } + if sel.Comment.ID != 1 { + t.Errorf("expected comment ID 1, got %d", sel.Comment.ID) + } +} + +func TestReviewInboxCommentMarkersForFile(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + markers := w.CommentMarkersForFile("main.go") + if len(markers) != 3 { + t.Fatalf("expected 3 markers for main.go, got %d", len(markers)) + } + + // Line 42 -> index 41 (0-based) + if _, ok := markers[41]; !ok { + t.Error("expected marker at line 41 (0-based for line 42)") + } + if _, ok := markers[77]; !ok { + t.Error("expected marker at line 77 (0-based for line 78)") + } + if _, ok := markers[94]; !ok { + t.Error("expected marker at line 94 (0-based for line 95)") + } + + // Check state + if markers[41].State != github.StateOpen { + t.Errorf("expected marker state Open, got %v", markers[41].State) + } +} + +func TestReviewInboxEmptyState(t *testing.T) { + w := NewReviewInboxWidget() + + if w.HasData() { + t.Error("expected HasData() = false for empty inbox") + } + if w.TotalComments() != 0 { + t.Error("expected 0 total comments") + } + if w.ProgressText() != "" { + t.Error("expected empty progress text") + } + if w.SelectedComment() != nil { + t.Error("expected nil selected comment") + } +} + +func TestReviewInboxToggleExpansion(t *testing.T) { + w := NewReviewInboxWidget() + state := github.NewReviewState("owner", "repo", 1) + comments := makeTestComments() + + w.SetComments(comments, state) + + // Count initial items (all groups expanded) + initialCount := len(w.items) + + // Collapse first group (main.go - 3 comments) + w.FileGroups[0].Expanded = false + w.buildItems() + + collapsedCount := len(w.items) + if collapsedCount != initialCount-3 { + t.Errorf("expected %d items after collapse, got %d", initialCount-3, collapsedCount) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + input string + maxLen int + want string + }{ + {"short", 10, "short"}, + {"this is a very long string", 10, "this is .."}, + {"hello\nworld", 20, "hello world"}, + {"", 10, ""}, + } + + for _, tt := range tests { + got := truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + } +} + +func TestStateIndicator(t *testing.T) { + tests := []struct { + state github.CommentState + ch rune + }{ + {github.StateOpen, '●'}, + {github.StateAddressed, '~'}, + {github.StateVerified, '✓'}, + {github.StateDismissed, '✗'}, + } + + for _, tt := range tests { + ch, _ := stateIndicator(tt.state) + if ch != tt.ch { + t.Errorf("stateIndicator(%v) = %c, want %c", tt.state, ch, tt.ch) + } + } +} diff --git a/internal/ui/statusbar_widget.go b/internal/ui/statusbar_widget.go index ec55ca08..019ed103 100644 --- a/internal/ui/statusbar_widget.go +++ b/internal/ui/statusbar_widget.go @@ -14,14 +14,14 @@ type statusBarSpan struct { type StatusBarWidget struct { BaseWidget - Status *view.StatusBar - OnIndentClick func() - OnEolClick func() - indentSpan statusBarSpan - eolSpan statusBarSpan - okSpan statusBarSpan - actionSpan statusBarSpan - secondarySpan statusBarSpan + Status *view.StatusBar + OnIndentClick func() + OnEolClick func() + indentSpan statusBarSpan + eolSpan statusBarSpan + okSpan statusBarSpan + actionSpan statusBarSpan + secondarySpan statusBarSpan } func NewStatusBarWidget(status *view.StatusBar) *StatusBarWidget { @@ -55,6 +55,13 @@ func (s *StatusBarWidget) Render(surface *RenderSurface) { x += s.drawText(surface, x, st.Blame, term.StyleStatusBar) } + if st.ReviewProgress != "" { + if x > 1 { + x += s.drawText(surface, x, " ", term.StyleStatusBar) + } + x += s.drawText(surface, x, st.ReviewProgress, term.StyleSuccess) + } + type segment struct { text string id string diff --git a/internal/view/statusbar.go b/internal/view/statusbar.go index 721df4be..c6cc913f 100644 --- a/internal/view/statusbar.go +++ b/internal/view/statusbar.go @@ -26,21 +26,22 @@ func (l NotifyLevel) Style() term.Style { } type StatusBar struct { - FileName string - Line int - Col int - Dirty bool - Branch string - Blame string - Language string - LSP bool - TabSize int - UseTabs bool - LineEnding string - CursorCount int - Notification string - NotifyLevel NotifyLevel - NotifyExpiry time.Time + FileName string + Line int + Col int + Dirty bool + Branch string + Blame string + Language string + LSP bool + TabSize int + UseTabs bool + LineEnding string + CursorCount int + ReviewProgress string + Notification string + NotifyLevel NotifyLevel + NotifyExpiry time.Time NotifyAction func() ActionLabel string SecondaryAction func() 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)) } }