diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index adc96283..24164734 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -252,6 +252,11 @@ func (a *App) OpenPRDiff(group *ui.ChangesGroup, status git.FileStatus, extended dv.OnFetchExtended = func(dv *ui.DiffViewWidget) { a.fetchPRFileContent(dv, group.PROwner, group.PRRepo, group.PRBaseSHA, group.PRHeadSHA, status.Path) } + // Set inline comments for this file + fileComments := github.CommentsForFile(group.Comments, status.Path) + if len(fileComments) > 0 { + dv.SetComments(fileComments) + } } a.FocusEditorIfEnabled() } @@ -371,6 +376,24 @@ func (a *App) ConfirmDiscard(message string, onConfirm func()) { ) } +// updateDiffComments updates inline comments on any open diff tabs belonging to a PR group. +func (a *App) updateDiffComments(groupName string, comments []github.PRComment) { + // Find the PR group to get the file list + for _, g := range a.Changes.Groups { + if !g.IsPR || g.Name != groupName { + continue + } + for _, f := range g.Unstaged { + tabName := f.Path + " (diff)" + if dv := a.EditorGroup.DiffWidgetByTab(tabName); dv != nil { + fileComments := github.CommentsForFile(comments, f.Path) + dv.SetComments(fileComments) + } + } + break + } +} + func registerWidgetCallbacks(app *App) { reg := app.Reg @@ -512,6 +535,13 @@ func registerWidgetCallbacks(app *App) { app.Changes.OnGroupMenu = app.ShowGroupMenu app.Changes.OnCommit = app.CommitChanges app.Changes.OnConfirmDiscard = app.ConfirmDiscard + app.Changes.OnAddComment = func(group *ui.ChangesGroup, body string) { + app.AddPRComment(group, body) + } + app.Changes.OnViewComment = func(comment github.PRComment) { + firstLine := strings.SplitN(comment.Body, "\n", 2)[0] + app.StatusNotify(fmt.Sprintf("@%s: %s", comment.User, firstLine)) + } app.ContentSplit.OnResize = func(height int) { if height <= 0 { diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index 7c33ef76..0ad7ae7a 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -288,14 +288,45 @@ func RunEventLoop( }) } groupName := fmt.Sprintf("PR #%d: %s", v.Info.Number, v.Info.Title) - app.Changes.AddPRGroup(groupName, v.URL, v.Info.Owner, v.Info.Repo, v.Info.BaseSHA, v.Info.HeadSHA, files, v.Diffs) + app.Changes.AddPRGroup(groupName, v.URL, v.Info.Owner, v.Info.Repo, v.Info.BaseSHA, v.Info.HeadSHA, v.Info.Number, files, v.Diffs) + if len(v.Comments) > 0 { + app.Changes.SetPRComments(groupName, v.Comments) + } app.Sidebar.SetActivePanel("changes") if !app.Sidebar.Visible { app.ShowSidebar() } 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))) + commentInfo := "" + if len(v.Comments) > 0 { + commentInfo = fmt.Sprintf(", %d comments", len(v.Comments)) + } + app.StatusNotify(fmt.Sprintf("Opened PR #%d: %s (%d files%s)", v.Info.Number, v.Info.Title, len(v.Info.Files), commentInfo)) + } + case *PrCommentAddResult: + if v.Err != nil { + app.StatusError("Failed to add comment: " + v.Err.Error()) + } else { + app.StatusNotify("Comment added") + // Clear the comment input and refresh comments + for i := range app.Changes.Groups { + if app.Changes.Groups[i].Name == v.GroupName { + if app.Changes.Groups[i].CommentInput != nil { + app.Changes.Groups[i].CommentInput.Clear() + } + app.RefreshPRComments(&app.Changes.Groups[i]) + break + } + } + } + case *PrCommentsRefreshResult: + if v.Err != nil { + app.StatusError("Failed to refresh comments: " + v.Err.Error()) + } else { + app.Changes.SetPRComments(v.GroupName, v.Comments) + // Also update any open diff tabs with new comments + app.updateDiffComments(v.GroupName, v.Comments) } } redraw() diff --git a/internal/app/lsp_convert.go b/internal/app/lsp_convert.go index e3e24e53..699fbc0c 100644 --- a/internal/app/lsp_convert.go +++ b/internal/app/lsp_convert.go @@ -36,7 +36,6 @@ type DiagnosticsResult struct { Diagnostics []ui.Diagnostic } - type SignatureHelpResult struct { Label string ParamStart int diff --git a/internal/app/pr.go b/internal/app/pr.go index a226bece..f21740f2 100644 --- a/internal/app/pr.go +++ b/internal/app/pr.go @@ -4,15 +4,17 @@ import ( "fmt" "github.com/eugenioenko/ttt/internal/github" + "github.com/eugenioenko/ttt/internal/ui" "github.com/gdamore/tcell/v2" ) type PrFetchResult struct { - URL string - Info *github.PRInfo - Diffs map[string]string - Err error + URL string + Info *github.PRInfo + Diffs map[string]string + Comments []github.PRComment + Err error } type DiffContentResult struct { @@ -22,6 +24,22 @@ type DiffContentResult struct { Err error } +// PrCommentAddResult carries the result of adding a PR comment. +type PrCommentAddResult struct { + GroupName string + Owner string + Repo string + Number int + Err error +} + +// PrCommentsRefreshResult carries refreshed comments for a PR group. +type PrCommentsRefreshResult struct { + GroupName string + Comments []github.PRComment + Err error +} + func (a *App) FetchAndOpenPR(url string) { owner, repo, number, err := github.ParsePRURL(url) if err != nil { @@ -46,6 +64,54 @@ func (a *App) FetchAndOpenPR(url string) { } diffs := github.SplitMultiFileDiff(diffText) - a.Screen.PostEvent(tcell.NewEventInterrupt(&PrFetchResult{URL: url, Info: info, Diffs: diffs})) + + // Also fetch PR comments (non-blocking - errors here are not fatal) + comments, _ := github.FetchPRComments(owner, repo, number) + + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrFetchResult{URL: url, Info: info, Diffs: diffs, Comments: comments})) + }() +} + +// AddPRComment adds a general comment to a PR and refreshes comments. +func (a *App) AddPRComment(group *ui.ChangesGroup, body string) { + if group == nil || group.PROwner == "" || group.PRNumber == 0 { + a.StatusError("Cannot add comment: PR info not available") + return + } + owner := group.PROwner + repo := group.PRRepo + number := group.PRNumber + groupName := group.Name + + a.StatusNotify("Adding comment...") + go func() { + err := github.AddPRComment(owner, repo, number, body) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrCommentAddResult{ + GroupName: groupName, + Owner: owner, + Repo: repo, + Number: number, + Err: err, + })) + }() +} + +// RefreshPRComments re-fetches comments for a PR group. +func (a *App) RefreshPRComments(group *ui.ChangesGroup) { + if group == nil || group.PROwner == "" || group.PRNumber == 0 { + return + } + owner := group.PROwner + repo := group.PRRepo + number := group.PRNumber + groupName := group.Name + + go func() { + comments, err := github.FetchPRComments(owner, repo, number) + a.Screen.PostEvent(tcell.NewEventInterrupt(&PrCommentsRefreshResult{ + GroupName: groupName, + Comments: comments, + Err: err, + })) }() } diff --git a/internal/app/theme.go b/internal/app/theme.go index 8179d904..0ae716e8 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -45,6 +45,8 @@ 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.StyleCommentBg, theme.Diff.CommentBg) + applyStyleDef(&m, term.StyleCommentAuthor, theme.Diff.CommentAuthor) 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..15422d00 100644 --- a/internal/app/widgets.go +++ b/internal/app/widgets.go @@ -1,15 +1,15 @@ package app import ( - "os" - "path/filepath" - "strings" "github.com/eugenioenko/ttt/internal/config" "github.com/eugenioenko/ttt/internal/github" "github.com/eugenioenko/ttt/internal/term" "github.com/eugenioenko/ttt/internal/ui" "github.com/eugenioenko/ttt/internal/view" "github.com/eugenioenko/ttt/internal/workspace" + "os" + "path/filepath" + "strings" ) func isPRURL(arg string) bool { @@ -177,27 +177,27 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work root.SetFocus(editorGroup) return &App{ - Root: root, - EditorGroup: editorGroup, - Sidebar: sidebar, - SplitPanel: splitPanel, - ContentSplit: contentSplit, - BottomPanel: bottomPanel, - Explorer: explorer, - Search: search, - Changes: changes, - MenuBar: menuBar, - StatusBar: statusBar, - Status: status, - Borders: borders, - Settings: &cfg.Settings, - Workspace: ws, - Palette: BuildTerminalPalettePtr(cfg.Theme), - TerminalPanel: terminalPanel, - Problems: problems, - References: references, - DocVersions: make(map[string]int), - AllDiagnostics: make(map[string][]ui.Diagnostic), - LspNotified: make(map[string]bool), + Root: root, + EditorGroup: editorGroup, + Sidebar: sidebar, + SplitPanel: splitPanel, + ContentSplit: contentSplit, + BottomPanel: bottomPanel, + Explorer: explorer, + Search: search, + Changes: changes, + MenuBar: menuBar, + StatusBar: statusBar, + Status: status, + Borders: borders, + Settings: &cfg.Settings, + Workspace: ws, + Palette: BuildTerminalPalettePtr(cfg.Theme), + TerminalPanel: terminalPanel, + Problems: problems, + References: references, + DocVersions: make(map[string]int), + AllDiagnostics: make(map[string][]ui.Diagnostic), + LspNotified: make(map[string]bool), } } diff --git a/internal/config/settings.go b/internal/config/settings.go index 96907ee0..86fd6268 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -73,15 +73,15 @@ func DefaultLSPSettings() LSPSettings { } type EditorSettings struct { - TabSize int `json:"tabSize"` - InsertSpaces bool `json:"insertSpaces"` - WordWrap bool `json:"wordWrap"` - LineNumbers bool `json:"lineNumbers"` - CursorStyle string `json:"cursorStyle,omitempty"` - FormatOnSave bool `json:"formatOnSave"` - InsertFinalNewline bool `json:"insertFinalNewline"` - TrimTrailingWhitespace bool `json:"trimTrailingWhitespace"` - FocusOnOpen bool `json:"focusOnOpen"` + TabSize int `json:"tabSize"` + InsertSpaces bool `json:"insertSpaces"` + WordWrap bool `json:"wordWrap"` + LineNumbers bool `json:"lineNumbers"` + CursorStyle string `json:"cursorStyle,omitempty"` + FormatOnSave bool `json:"formatOnSave"` + InsertFinalNewline bool `json:"insertFinalNewline"` + TrimTrailingWhitespace bool `json:"trimTrailingWhitespace"` + FocusOnOpen bool `json:"focusOnOpen"` GitGutter *bool `json:"gitGutter,omitempty"` GutterStyle string `json:"gutterStyle,omitempty"` BracketPairColorization bool `json:"bracketPairColorization"` @@ -95,10 +95,10 @@ func (e EditorSettings) IsGitGutterEnabled() bool { func DefaultEditorSettings() EditorSettings { return EditorSettings{ - TabSize: 4, - InsertSpaces: true, - LineNumbers: true, - InsertFinalNewline: true, + TabSize: 4, + InsertSpaces: true, + LineNumbers: true, + InsertFinalNewline: true, GutterStyle: "compact", BracketPairColorization: false, } @@ -168,7 +168,6 @@ func LoadSettings() Settings { return s } - func SaveSettings(s Settings) error { path := ConfigFilePath("settings.json") data, err := json.MarshalIndent(s, "", " ") diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 4adc6817..70a87dad 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -205,7 +205,6 @@ func TestDefaultLSPSettings(t *testing.T) { } } - func TestReferenceSettingsMatchesDefaults(t *testing.T) { _, thisFile, _, _ := runtime.Caller(0) refPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "config", "settings.json") diff --git a/internal/config/theme.go b/internal/config/theme.go index c12a656f..fffeabfb 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -73,6 +73,8 @@ type DiffStyles struct { GutterAdded StyleDef `json:"gutterAdded,omitempty"` GutterDeleted StyleDef `json:"gutterDeleted,omitempty"` GutterModified StyleDef `json:"gutterModified,omitempty"` + CommentBg StyleDef `json:"commentBg,omitempty"` + CommentAuthor StyleDef `json:"commentAuthor,omitempty"` } type SyntaxStyles struct { @@ -235,11 +237,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"}, }, @@ -288,6 +290,12 @@ func (t *ThemeConfig) ResolveColors() { fillFg(&t.Diff.GutterAdded, "#73c991") fillFg(&t.Diff.GutterDeleted, "#f14c4c") fillFg(&t.Diff.GutterModified, "#e2c08d") + fillBg(&t.Diff.CommentBg, "#2d2d30") + fillFg(&t.Diff.CommentBg, t.Default.Fg) + fillFg(&t.Diff.CommentAuthor, "#569cd6") + if !t.Diff.CommentAuthor.Bold { + t.Diff.CommentAuthor.Bold = true + } fillFg(&t.Success, "#73c991") fillFg(&t.Danger, "#f14c4c") fillFg(&t.Warning, "#e2c08d") diff --git a/internal/core/buffer/buffer.go b/internal/core/buffer/buffer.go index 8300d6fe..97ef62be 100644 --- a/internal/core/buffer/buffer.go +++ b/internal/core/buffer/buffer.go @@ -70,8 +70,8 @@ func DetectIndent(lines []string) IndentInfo { // Buffer represents a text buffer with line-based storage. type Buffer struct { - Lines []string - Dirty bool + Lines []string + Dirty bool InsertFinalNewline bool TrimTrailingWhitespace bool LineEnding string // "\n" (LF) or "\r\n" (CRLF); defaults to "\n" diff --git a/internal/core/diff/diff.go b/internal/core/diff/diff.go index 0428ba9a..e4829faf 100644 --- a/internal/core/diff/diff.go +++ b/internal/core/diff/diff.go @@ -8,7 +8,7 @@ import ( type LineKind int const ( - Blank LineKind = iota + Blank LineKind = iota Context Added Deleted diff --git a/internal/core/fold/fold.go b/internal/core/fold/fold.go index 0b74335c..960c0d46 100644 --- a/internal/core/fold/fold.go +++ b/internal/core/fold/fold.go @@ -11,8 +11,8 @@ type State struct { ranges []Range collapsed map[int]bool - dirty bool - cachedVisible []int + dirty bool + cachedVisible []int cachedBufToVis map[int]int } diff --git a/internal/core/highlight/highlighter.go b/internal/core/highlight/highlighter.go index 3c5b1238..ca53cae1 100644 --- a/internal/core/highlight/highlighter.go +++ b/internal/core/highlight/highlighter.go @@ -1,8 +1,8 @@ package highlight import ( - "strings" "github.com/eugenioenko/ttt/internal/term" + "strings" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" diff --git a/internal/core/highlight/highlighter_test.go b/internal/core/highlight/highlighter_test.go index d0fae9eb..b70bd145 100644 --- a/internal/core/highlight/highlighter_test.go +++ b/internal/core/highlight/highlighter_test.go @@ -1,8 +1,8 @@ package highlight import ( - "testing" "github.com/eugenioenko/ttt/internal/term" + "testing" ) func TestHighlightGo_Comment(t *testing.T) { diff --git a/internal/github/github.go b/internal/github/github.go index f258ae89..7be6c01b 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -13,6 +13,16 @@ type PRFile struct { Status string // A, M, D, R } +type PRComment struct { + ID int + Body string + User string + CreatedAt string + Path string // empty for general comments + Line int // 0 for general comments + IsInline bool +} + type PRInfo struct { Owner string Repo string @@ -133,6 +143,180 @@ func FetchFileContent(owner, repo, path, ref string) (string, error) { return string(out), nil } +// FetchPRComments fetches both inline review comments and general issue comments +// for a pull request. Returns them as a unified slice sorted by creation time. +func FetchPRComments(owner, repo string, number int) ([]PRComment, error) { + repoArg := owner + "/" + repo + numStr := strconv.Itoa(number) + + // Fetch inline review comments (on specific lines of code) + reviewCmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/pulls/%s/comments", repoArg, numStr), + "--paginate") + reviewOut, err := reviewCmd.Output() + if err != nil { + return nil, fmt.Errorf("gh api pull comments failed: %w", err) + } + + var reviewComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + Path string `json:"path"` + Line *int `json:"line"` + } + if len(reviewOut) > 0 { + if err := json.Unmarshal(reviewOut, &reviewComments); err != nil { + return nil, fmt.Errorf("parse review comments: %w", err) + } + } + + // Fetch general issue comments (not attached to specific lines) + issueCmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/issues/%s/comments", repoArg, numStr), + "--paginate") + issueOut, err := issueCmd.Output() + if err != nil { + return nil, fmt.Errorf("gh api issue comments failed: %w", err) + } + + var issueComments []struct { + ID int `json:"id"` + Body string `json:"body"` + User struct { + Login string `json:"login"` + } `json:"user"` + CreatedAt string `json:"created_at"` + } + if len(issueOut) > 0 { + if err := json.Unmarshal(issueOut, &issueComments); err != nil { + return nil, fmt.Errorf("parse issue comments: %w", err) + } + } + + var comments []PRComment + + for _, rc := range reviewComments { + line := 0 + if rc.Line != nil { + line = *rc.Line + } + comments = append(comments, PRComment{ + ID: rc.ID, + Body: rc.Body, + User: rc.User.Login, + CreatedAt: rc.CreatedAt, + Path: rc.Path, + Line: line, + IsInline: true, + }) + } + + for _, ic := range issueComments { + comments = append(comments, PRComment{ + ID: ic.ID, + Body: ic.Body, + User: ic.User.Login, + CreatedAt: ic.CreatedAt, + IsInline: false, + }) + } + + return comments, nil +} + +// AddPRComment adds a general comment to a pull request. +func AddPRComment(owner, repo string, number int, body string) error { + repoArg := owner + "/" + repo + numStr := strconv.Itoa(number) + payload, _ := json.Marshal(map[string]string{"body": body}) + cmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/issues/%s/comments", repoArg, numStr), + "-X", "POST", + "--input", "-") + cmd.Stdin = strings.NewReader(string(payload)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("add comment failed: %w: %s", err, string(out)) + } + return nil +} + +// AddPRInlineComment adds an inline review comment on a specific file and line. +func AddPRInlineComment(owner, repo string, number int, body, path string, line int) error { + repoArg := owner + "/" + repo + numStr := strconv.Itoa(number) + + // First get the HEAD commit SHA for the PR + cmd := exec.Command("gh", "pr", "view", numStr, + "--repo", repoArg, + "--json", "headRefOid", "--jq", ".headRefOid") + shaOut, err := cmd.Output() + if err != nil { + return fmt.Errorf("get PR head SHA failed: %w", err) + } + commitSHA := strings.TrimSpace(string(shaOut)) + + payload, _ := json.Marshal(map[string]interface{}{ + "body": body, + "commit_id": commitSHA, + "path": path, + "line": line, + }) + postCmd := exec.Command("gh", "api", + fmt.Sprintf("repos/%s/pulls/%s/comments", repoArg, numStr), + "-X", "POST", + "--input", "-") + postCmd.Stdin = strings.NewReader(string(payload)) + if out, err := postCmd.CombinedOutput(); err != nil { + return fmt.Errorf("add inline comment failed: %w: %s", err, string(out)) + } + return nil +} + +// FormatCommentTime formats a GitHub API timestamp into a short relative or absolute form. +func FormatCommentTime(createdAt string) string { + if len(createdAt) >= 10 { + return createdAt[:10] + } + return createdAt +} + +// CommentsForFile returns only the inline comments for a specific file path. +func CommentsForFile(comments []PRComment, path string) []PRComment { + var result []PRComment + for _, c := range comments { + if c.IsInline && c.Path == path { + result = append(result, c) + } + } + return result +} + +// GeneralComments returns only the non-inline (general) comments. +func GeneralComments(comments []PRComment) []PRComment { + var result []PRComment + for _, c := range comments { + if !c.IsInline { + result = append(result, c) + } + } + return result +} + +// FileCommentCounts returns a map of file path to number of inline comments. +func FileCommentCounts(comments []PRComment) map[string]int { + counts := make(map[string]int) + for _, c := range comments { + if c.IsInline && c.Path != "" { + counts[c.Path]++ + } + } + return counts +} + 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..dabc21ec 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -38,6 +38,104 @@ func TestParsePRURL(t *testing.T) { } } +func TestFormatCommentTime(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"2024-01-15T10:30:00Z", "2024-01-15"}, + {"2024-12-01", "2024-12-01"}, + {"short", "short"}, + {"", ""}, + } + for _, tt := range tests { + got := FormatCommentTime(tt.input) + if got != tt.want { + t.Errorf("FormatCommentTime(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestCommentsForFile(t *testing.T) { + comments := []PRComment{ + {ID: 1, Body: "inline on main.go", Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Body: "inline on util.go", Path: "util.go", Line: 5, IsInline: true}, + {ID: 3, Body: "general comment", IsInline: false}, + {ID: 4, Body: "another on main.go", Path: "main.go", Line: 20, IsInline: true}, + } + + mainComments := CommentsForFile(comments, "main.go") + if len(mainComments) != 2 { + t.Errorf("expected 2 comments for main.go, got %d", len(mainComments)) + } + if mainComments[0].ID != 1 || mainComments[1].ID != 4 { + t.Errorf("wrong comments returned for main.go") + } + + utilComments := CommentsForFile(comments, "util.go") + if len(utilComments) != 1 { + t.Errorf("expected 1 comment for util.go, got %d", len(utilComments)) + } + + noComments := CommentsForFile(comments, "nonexistent.go") + if len(noComments) != 0 { + t.Errorf("expected 0 comments for nonexistent.go, got %d", len(noComments)) + } +} + +func TestGeneralComments(t *testing.T) { + comments := []PRComment{ + {ID: 1, Body: "inline", Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Body: "general 1", IsInline: false}, + {ID: 3, Body: "general 2", IsInline: false}, + {ID: 4, Body: "inline 2", Path: "util.go", Line: 5, IsInline: true}, + } + + general := GeneralComments(comments) + if len(general) != 2 { + t.Fatalf("expected 2 general comments, got %d", len(general)) + } + if general[0].ID != 2 || general[1].ID != 3 { + t.Errorf("wrong general comments returned") + } +} + +func TestFileCommentCounts(t *testing.T) { + comments := []PRComment{ + {ID: 1, Path: "main.go", Line: 10, IsInline: true}, + {ID: 2, Path: "main.go", Line: 20, IsInline: true}, + {ID: 3, Path: "util.go", Line: 5, IsInline: true}, + {ID: 4, IsInline: false}, // general comment - no path + } + + counts := FileCommentCounts(comments) + if counts["main.go"] != 2 { + t.Errorf("expected 2 for main.go, got %d", counts["main.go"]) + } + if counts["util.go"] != 1 { + t.Errorf("expected 1 for util.go, got %d", counts["util.go"]) + } + if counts["nonexistent.go"] != 0 { + t.Errorf("expected 0 for nonexistent.go, got %d", counts["nonexistent.go"]) + } +} + +func TestGeneralCommentsEmpty(t *testing.T) { + var comments []PRComment + general := GeneralComments(comments) + if len(general) != 0 { + t.Errorf("expected 0 general comments from nil slice, got %d", len(general)) + } +} + +func TestFileCommentCountsEmpty(t *testing.T) { + var comments []PRComment + counts := FileCommentCounts(comments) + if len(counts) != 0 { + t.Errorf("expected empty map from nil slice, got %d entries", len(counts)) + } +} + func TestSplitMultiFileDiff(t *testing.T) { unified := `diff --git a/file1.go b/file1.go --- a/file1.go diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 788db58a..8fe9816e 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -17,9 +17,9 @@ type Client struct { mu sync.Mutex done chan struct{} - completionTriggers []string - signatureTriggers []string - signatureRetriggers []string + completionTriggers []string + signatureTriggers []string + signatureRetriggers []string OnDiagnostics func(params PublishDiagnosticsParams) } diff --git a/internal/lsp/jsonrpc.go b/internal/lsp/jsonrpc.go index 164a563b..6961dfb6 100644 --- a/internal/lsp/jsonrpc.go +++ b/internal/lsp/jsonrpc.go @@ -11,10 +11,10 @@ import ( ) type Request struct { - JSONRPC string `json:"jsonrpc"` - ID *int `json:"id,omitempty"` - Method string `json:"method"` - Params any `json:"params,omitempty"` + JSONRPC string `json:"jsonrpc"` + ID *int `json:"id,omitempty"` + Method string `json:"method"` + Params any `json:"params,omitempty"` } type Response struct { diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index b2a9ca9f..7b2f6f33 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -67,7 +67,6 @@ func (m *Manager) ClientForLanguage(lang, workDir string) (*Client, error) { return client, nil } - func (m *Manager) SignatureHelpTriggerCharacters(serverKey string) []string { m.mu.Lock() client, ok := m.servers[serverKey] diff --git a/internal/lsp/protocol.go b/internal/lsp/protocol.go index 205cac96..d543f75c 100644 --- a/internal/lsp/protocol.go +++ b/internal/lsp/protocol.go @@ -29,8 +29,8 @@ type TextDocumentPositionParams struct { } type InitializeParams struct { - ProcessID int `json:"processId"` - RootURI string `json:"rootUri"` + ProcessID int `json:"processId"` + RootURI string `json:"rootUri"` Capabilities ClientCapabilities `json:"capabilities"` } @@ -39,7 +39,7 @@ type ClientCapabilities struct { } type TextDocumentClientCapabilities struct { - Completion *CompletionClientCapabilities `json:"completion,omitempty"` + Completion *CompletionClientCapabilities `json:"completion,omitempty"` PublishDiagnostics *PublishDiagnosticsClientCapabilities `json:"publishDiagnostics,omitempty"` } @@ -52,8 +52,8 @@ type CompletionClientCapabilities struct { } type CompletionItemClientCapabilities struct { - SnippetSupport bool `json:"snippetSupport"` - ResolveSupport *CompletionItemResolveSupport `json:"resolveSupport,omitempty"` + SnippetSupport bool `json:"snippetSupport"` + ResolveSupport *CompletionItemResolveSupport `json:"resolveSupport,omitempty"` } type CompletionItemResolveSupport struct { @@ -68,8 +68,8 @@ type ServerCapabilities struct { CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"` SignatureHelpProvider *SignatureHelpOptions `json:"signatureHelpProvider,omitempty"` TextDocumentSync *TextDocumentSyncOptions `json:"textDocumentSync,omitempty"` - DocumentFormattingProvider BoolOrObject `json:"documentFormattingProvider,omitempty"` - DocumentRangeFormattingProvider BoolOrObject `json:"documentRangeFormattingProvider,omitempty"` + DocumentFormattingProvider BoolOrObject `json:"documentFormattingProvider,omitempty"` + DocumentRangeFormattingProvider BoolOrObject `json:"documentRangeFormattingProvider,omitempty"` } type SignatureHelpOptions struct { @@ -164,7 +164,7 @@ type ReferenceContext struct { type ReferenceParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` Position Position `json:"position"` - Context ReferenceContext `json:"context"` + Context ReferenceContext `json:"context"` } type RenameParams struct { diff --git a/internal/term/screen.go b/internal/term/screen.go index d2d05e49..ea1e0750 100644 --- a/internal/term/screen.go +++ b/internal/term/screen.go @@ -58,6 +58,8 @@ const ( StyleGutterAdded StyleGutterModified StyleGutterDeleted + StyleCommentBg + StyleCommentAuthor ) // 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..b337faf9 100644 --- a/internal/term/screen_test.go +++ b/internal/term/screen_test.go @@ -45,7 +45,7 @@ func TestParseCursorStyle(t *testing.T) { {"blinkingUnderline", CursorStyleBlinkingUnderline}, {"steadyUnderline", CursorStyleSteadyUnderline}, {"unknown", CursorStyleBlinkingBar}, - {"BLOCK", CursorStyleBlinkingBar}, // case sensitive, falls through to default + {"BLOCK", CursorStyleBlinkingBar}, // case sensitive, falls through to default {"invalid", CursorStyleBlinkingBar}, } for _, tt := range tests { diff --git a/internal/term/tcell_screen.go b/internal/term/tcell_screen.go index 3b8b8c63..30e6b060 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 = 57 type StyleMap [StyleCount]tcell.Style diff --git a/internal/ui/base.go b/internal/ui/base.go index d26e54f9..76dfc23e 100644 --- a/internal/ui/base.go +++ b/internal/ui/base.go @@ -6,8 +6,8 @@ type BaseWidget struct { rect Rect } -func (b *BaseWidget) SetRect(r Rect) { b.rect = r } -func (b *BaseWidget) GetRect() Rect { return b.rect } -func (b *BaseWidget) HandleEvent(ev tcell.Event) EventResult { return EventIgnored } -func (b *BaseWidget) Render(surface *RenderSurface) {} -func (b *BaseWidget) Focusable() bool { return false } +func (b *BaseWidget) SetRect(r Rect) { b.rect = r } +func (b *BaseWidget) GetRect() Rect { return b.rect } +func (b *BaseWidget) HandleEvent(ev tcell.Event) EventResult { return EventIgnored } +func (b *BaseWidget) Render(surface *RenderSurface) {} +func (b *BaseWidget) Focusable() bool { return false } diff --git a/internal/ui/changes_widget.go b/internal/ui/changes_widget.go index 5746f2ee..ffcc29c7 100644 --- a/internal/ui/changes_widget.go +++ b/internal/ui/changes_widget.go @@ -2,7 +2,10 @@ package ui import ( "fmt" + "strings" + "github.com/eugenioenko/ttt/internal/git" + "github.com/eugenioenko/ttt/internal/github" "github.com/eugenioenko/ttt/internal/term" "path/filepath" @@ -25,6 +28,11 @@ type ChangesGroup struct { PRRepo string PRBaseSHA string PRHeadSHA string + PRNumber int + Comments []github.PRComment + ConvoExpanded bool + CommentInput *InputWidget + CommentCounts map[string]int // file path -> inline comment count } type changesItemKind int @@ -36,13 +44,17 @@ const ( itemBorder itemSection itemSpacer + itemConvoSection + itemComment + itemCommentInput ) type changesItem struct { - kind changesItemKind - groupIndex int - fileIndex int - staged bool + kind changesItemKind + groupIndex int + fileIndex int + commentIndex int + staged bool } type ChangesWidget struct { @@ -63,6 +75,8 @@ type ChangesWidget struct { OnPRGroupMenu func(group *ChangesGroup, screenX, screenY int) OnRefreshPR func(url string) OnConfirmDiscard func(message string, onConfirm func()) + OnAddComment func(group *ChangesGroup, body string) + OnViewComment func(comment github.PRComment) } func NewChangesWidget(dirs ...string) *ChangesWidget { @@ -155,6 +169,24 @@ func (c *ChangesWidget) buildItems() { if showHeader { c.items = append(c.items, changesItem{kind: itemBorder, groupIndex: gi}) } + + // Conversation section for PR groups with comments + if g.IsPR && len(g.Comments) > 0 { + generalComments := github.GeneralComments(g.Comments) + if len(generalComments) > 0 || g.CommentInput != nil { + c.items = append(c.items, changesItem{kind: itemConvoSection, groupIndex: gi}) + if g.ConvoExpanded { + for ci, comment := range g.Comments { + if !comment.IsInline { + c.items = append(c.items, changesItem{kind: itemComment, groupIndex: gi, commentIndex: ci}) + } + } + c.items = append(c.items, changesItem{kind: itemCommentInput, groupIndex: gi}) + } + c.items = append(c.items, changesItem{kind: itemBorder, groupIndex: gi}) + } + } + if !g.IsPR { c.items = append(c.items, changesItem{kind: itemInput, groupIndex: gi}) c.items = append(c.items, changesItem{kind: itemBorder, groupIndex: gi}) @@ -293,6 +325,15 @@ func (c *ChangesWidget) Render(surface *RenderSurface) { c.renderSectionHeader(surface, y, w, style, item) case itemFile: c.renderFile(surface, y, w, style, idx == c.Selected && !c.inputFocused, item) + case itemConvoSection: + c.renderConvoSection(surface, y, w, style, item) + case itemComment: + c.renderComment(surface, y, w, style, item) + case itemCommentInput: + g := c.Groups[item.groupIndex] + if g.CommentInput != nil { + g.CommentInput.Render(surface, indent, y, w-indent) + } } } } @@ -398,10 +439,22 @@ func (c *ChangesWidget) renderFile(surface *RenderSurface, y, w int, style term. x++ } - isPR := c.Groups[item.groupIndex].IsPR + g2 := c.Groups[item.groupIndex] + isPR := g2.IsPR + + // Reserve space for comment count badge on PR files + commentCount := 0 + if isPR && g2.CommentCounts != nil { + commentCount = g2.CommentCounts[f.Path] + } + badgeSuffix := "" + if commentCount > 0 { + badgeSuffix = fmt.Sprintf(" (%d)", commentCount) + } + maxPathX := w - 4 if isPR { - maxPathX = w - 1 + maxPathX = w - 1 - len([]rune(badgeSuffix)) } for _, ch := range f.Path { if x >= maxPathX { @@ -411,6 +464,20 @@ func (c *ChangesWidget) renderFile(surface *RenderSurface, y, w int, style term. x++ } + // Render comment count badge + if commentCount > 0 { + badgeStyle := term.StyleMuted + if selected { + badgeStyle = style + } + for _, ch := range badgeSuffix { + if x < w { + surface.SetCell(x, y, term.Cell{Ch: ch, Style: badgeStyle}) + x++ + } + } + } + if !isPR { if item.staged { if w >= 3 { @@ -424,6 +491,82 @@ func (c *ChangesWidget) renderFile(surface *RenderSurface, y, w int, style term. } } +func (c *ChangesWidget) renderConvoSection(surface *RenderSurface, y, w int, style term.Style, item changesItem) { + g := c.Groups[item.groupIndex] + generalCount := len(github.GeneralComments(g.Comments)) + + x := 1 + chevron := '▶' + if g.ConvoExpanded { + chevron = '▼' + } + labelStyle := term.StyleMuted + if style == term.StyleSidebarSelected { + labelStyle = style + } + + if x < w { + surface.SetCell(x, y, term.Cell{Ch: chevron, Style: labelStyle}) + x += 2 + } + + label := fmt.Sprintf("Conversation (%d)", generalCount) + for _, rch := range label { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: rch, Style: labelStyle}) + x++ + } +} + +func (c *ChangesWidget) renderComment(surface *RenderSurface, y, w int, style term.Style, item changesItem) { + g := c.Groups[item.groupIndex] + if item.commentIndex < 0 || item.commentIndex >= len(g.Comments) { + return + } + comment := g.Comments[item.commentIndex] + + x := 2 + + // Render @user prefix + user := "@" + comment.User + userStyle := term.StyleCommentAuthor + if style == term.StyleSidebarSelected { + userStyle = style + } + for _, ch := range user { + if x >= w-1 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: userStyle}) + x++ + } + + if x < w-1 { + surface.SetCell(x, y, term.Cell{Ch: ':', Style: style}) + x++ + } + if x < w-1 { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: style}) + x++ + } + + // Show first line of comment body, truncated + bodyLine := strings.SplitN(comment.Body, "\n", 2)[0] + bodyStyle := term.StyleMuted + if style == term.StyleSidebarSelected { + bodyStyle = style + } + for _, ch := range bodyLine { + if x >= w-1 { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: bodyStyle}) + x++ + } +} + func statusStyle(status string) term.Style { switch status { case "M": @@ -460,10 +603,19 @@ func (c *ChangesWidget) CursorPosition() (int, int, bool) { } r := c.GetRect() for i, item := range c.items { - if item.kind == itemInput && i == c.Selected { - inp := c.Groups[item.groupIndex].Input - y := r.Y + i - c.ScrollTop - return inp.CursorX(r.X), y, true + if i == c.Selected { + if item.kind == itemInput { + inp := c.Groups[item.groupIndex].Input + y := r.Y + i - c.ScrollTop + return inp.CursorX(r.X), y, true + } + if item.kind == itemCommentInput { + inp := c.Groups[item.groupIndex].CommentInput + if inp != nil { + y := r.Y + i - c.ScrollTop + return inp.CursorX(r.X), y, true + } + } } } return 0, 0, false @@ -478,6 +630,9 @@ func (c *ChangesWidget) FocusedInput() *InputWidget { if item.kind == itemInput { return c.Groups[item.groupIndex].Input } + if item.kind == itemCommentInput { + return c.Groups[item.groupIndex].CommentInput + } } return nil } @@ -491,7 +646,11 @@ func (c *ChangesWidget) HandleEvent(ev tcell.Event) EventResult { return EventConsumed case tcell.KeyEnter: item := c.items[c.Selected] - c.commitGroup(item.groupIndex) + if item.kind == itemCommentInput { + c.submitComment(item.groupIndex) + } else { + c.commitGroup(item.groupIndex) + } return EventConsumed case tcell.KeyUp: c.inputFocused = false @@ -507,7 +666,14 @@ func (c *ChangesWidget) HandleEvent(ev tcell.Event) EventResult { return EventConsumed default: item := c.items[c.Selected] - c.Groups[item.groupIndex].Input.HandleEvent(ev) + if item.kind == itemCommentInput { + g := c.Groups[item.groupIndex] + if g.CommentInput != nil { + g.CommentInput.HandleEvent(ev) + } + } else { + c.Groups[item.groupIndex].Input.HandleEvent(ev) + } return EventConsumed } } @@ -554,6 +720,15 @@ func (c *ChangesWidget) HandleEvent(ev tcell.Event) EventResult { c.Groups[item.groupIndex].Input.HandleClick(mx, my) return EventConsumed } + if item.kind == itemCommentInput { + c.Selected = idx + c.inputFocused = true + g := c.Groups[item.groupIndex] + if g.CommentInput != nil { + g.CommentInput.HandleClick(mx, my) + } + return EventConsumed + } if item.kind == itemFile && mx >= r.X+r.W-3 { c.Selected = idx c.handleFileAction(item) @@ -709,6 +884,19 @@ func (c *ChangesWidget) activateSelected() { c.buildItems() case itemInput: c.inputFocused = true + case itemCommentInput: + c.inputFocused = true + case itemConvoSection: + g := &c.Groups[item.groupIndex] + g.ConvoExpanded = !g.ConvoExpanded + c.buildItems() + case itemComment: + g := c.Groups[item.groupIndex] + if item.commentIndex >= 0 && item.commentIndex < len(g.Comments) { + if c.OnViewComment != nil { + c.OnViewComment(g.Comments[item.commentIndex]) + } + } case itemSection: g := &c.Groups[item.groupIndex] if item.staged { @@ -801,6 +989,19 @@ func (c *ChangesWidget) confirmDiscardAll(gi int) { }) } +func (c *ChangesWidget) submitComment(gi int) { + if gi < 0 || gi >= len(c.Groups) { + return + } + g := &c.Groups[gi] + if g.CommentInput == nil || g.CommentInput.Text == "" { + return + } + if c.OnAddComment != nil { + c.OnAddComment(g, g.CommentInput.Text) + } +} + func (c *ChangesWidget) selectedInPR() bool { if c.Selected < 0 || c.Selected >= len(c.items) { return false @@ -808,7 +1009,9 @@ func (c *ChangesWidget) selectedInPR() bool { return c.Groups[c.items[c.Selected].groupIndex].IsPR } -func (c *ChangesWidget) AddPRGroup(name, url, owner, repo, baseSHA, headSHA string, files []git.FileStatus, diffs map[string]string) { +func (c *ChangesWidget) AddPRGroup(name, url, owner, repo, baseSHA, headSHA string, number int, files []git.FileStatus, diffs map[string]string) { + commentInput := NewInputWidget() + commentInput.Placeholder = "Add comment..." c.Groups = append(c.Groups, ChangesGroup{ Dir: "pr://" + name, Name: name, @@ -822,12 +1025,28 @@ func (c *ChangesWidget) AddPRGroup(name, url, owner, repo, baseSHA, headSHA stri PRRepo: repo, PRBaseSHA: baseSHA, PRHeadSHA: headSHA, + PRNumber: number, + ConvoExpanded: true, + CommentInput: commentInput, }) c.multiRoot = len(c.Groups) > 1 c.buildItems() c.ClampSelected(len(c.items)) } +// SetPRComments updates comments for a PR group and rebuilds the item list. +func (c *ChangesWidget) SetPRComments(name string, comments []github.PRComment) { + for i := range c.Groups { + if c.Groups[i].IsPR && c.Groups[i].Name == name { + c.Groups[i].Comments = comments + c.Groups[i].CommentCounts = github.FileCommentCounts(comments) + break + } + } + c.buildItems() + c.ClampSelected(len(c.items)) +} + func (c *ChangesWidget) RemovePRGroup(name string) { var kept []ChangesGroup for _, g := range c.Groups { diff --git a/internal/ui/content_split.go b/internal/ui/content_split.go index e55da733..64cbdbfe 100644 --- a/internal/ui/content_split.go +++ b/internal/ui/content_split.go @@ -8,18 +8,18 @@ import ( type ContentSplitWidget struct { BaseWidget - Top Widget - Bottom Widget - ShowBottom bool - BottomH int - Borders *term.BorderSet - OnResize func(height int) - OnBottomClick func() - OnTopClick func() - RightBorderStartY *int - dragging bool - wasPressed bool - capturedChild Widget + Top Widget + Bottom Widget + ShowBottom bool + BottomH int + Borders *term.BorderSet + OnResize func(height int) + OnBottomClick func() + OnTopClick func() + RightBorderStartY *int + dragging bool + wasPressed bool + capturedChild Widget } func NewContentSplitWidget() *ContentSplitWidget { diff --git a/internal/ui/contextmenu_widget.go b/internal/ui/contextmenu_widget.go index ab647150..09e49ad3 100644 --- a/internal/ui/contextmenu_widget.go +++ b/internal/ui/contextmenu_widget.go @@ -25,15 +25,15 @@ func MenuSep() ContextMenuItem { type ContextMenuWidget struct { BaseWidget - Items []ContextMenuItem - Selected int - AnchorX int - AnchorY int - Borders *term.BorderSet - OnExec func(command string) - OnDismiss func() - OnNavigate func(dir int) - firstEvent bool + Items []ContextMenuItem + Selected int + AnchorX int + AnchorY int + Borders *term.BorderSet + OnExec func(command string) + OnDismiss func() + OnNavigate func(dir int) + firstEvent bool } func NewContextMenuWidget(items []ContextMenuItem, x, y int) *ContextMenuWidget { diff --git a/internal/ui/debouncer.go b/internal/ui/debouncer.go index ab1e1312..178bcbca 100644 --- a/internal/ui/debouncer.go +++ b/internal/ui/debouncer.go @@ -6,11 +6,11 @@ import ( ) type Debouncer struct { - DelayMs int - OnFinish func() - timer *time.Timer - mu sync.Mutex - gen uint64 + DelayMs int + OnFinish func() + timer *time.Timer + mu sync.Mutex + gen uint64 } func (d *Debouncer) Schedule(fn func()) { diff --git a/internal/ui/diff_widget.go b/internal/ui/diff_widget.go index 1cff3356..146ec2fa 100644 --- a/internal/ui/diff_widget.go +++ b/internal/ui/diff_widget.go @@ -9,14 +9,15 @@ import ( "github.com/eugenioenko/ttt/internal/core/diff" "github.com/eugenioenko/ttt/internal/core/highlight" + "github.com/eugenioenko/ttt/internal/github" "github.com/eugenioenko/ttt/internal/term" "github.com/gdamore/tcell/v2" ) type diffMergedRef struct { - isRight bool - sideIdx int + isRight bool + sideIdx int } type diffSelPos struct { @@ -34,41 +35,60 @@ type DiffViewWidget struct { maxLineW int viewH int contentW int - scrollbar Scrollbar - hscrollbar HScrollbar - rhscrollbar HScrollbar + scrollbar Scrollbar + hscrollbar HScrollbar + rhscrollbar HScrollbar // layout cache for mouse hit-testing - layoutDividerX int - layoutLeftStart int - layoutLeftW int + layoutDividerX int + layoutLeftStart int + layoutLeftW int layoutRightStart int - layoutRightW int - layoutGutterW int + layoutRightW int + layoutGutterW int // selection state - selecting bool - hasSelection bool - selRight bool - selAnchor diffSelPos - selCurrent diffSelPos + selecting bool + hasSelection bool + selRight bool + selAnchor diffSelPos + selCurrent diffSelPos lastClickTime time.Time lastClickPos diffSelPos - SearchMatchesLeft []FindMatch - SearchMatchesRight []FindMatch - searchMergedRefs []diffMergedRef - searchActiveRight bool + SearchMatchesLeft []FindMatch + SearchMatchesRight []FindMatch + searchMergedRefs []diffMergedRef + searchActiveRight bool searchActiveSideIdx int // extended diff mode - extended bool - fileDiff diff.FileDiff - oldLines []string - newLines []string + extended bool + fileDiff diff.FileDiff + oldLines []string + newLines []string OnFetchExtended func(dv *DiffViewWidget) Loading bool + + // PR inline comments + Comments []github.PRComment + displayRows []diffDisplayRow // flattened: diff lines interleaved with comment rows +} + +type diffRowKind int + +const ( + diffRowLine diffRowKind = iota // a normal diff line + diffRowComment // a comment block row +) + +type diffDisplayRow struct { + kind diffRowKind + lineIdx int // index into d.Lines for diffRowLine + comment *github.PRComment + commentLine int // which line of the wrapped comment text this row represents + commentText string // pre-computed text for this row } func NewDiffViewWidget(filePath string, fd diff.FileDiff, oldLines, newLines []string, extended bool) *DiffViewWidget { @@ -137,6 +157,66 @@ func (d *DiffViewWidget) rebuildLines() { } } d.maxLineW = maxW + d.buildDisplayRows() +} + +// SetComments sets the inline comments for this diff view and rebuilds display rows. +func (d *DiffViewWidget) SetComments(comments []github.PRComment) { + d.Comments = comments + d.buildDisplayRows() +} + +// buildDisplayRows creates a flat list of display rows interleaving diff lines with comment blocks. +// Comments are placed after the diff line matching their line number on the right side. +func (d *DiffViewWidget) buildDisplayRows() { + d.displayRows = nil + if len(d.Comments) == 0 { + // No comments - just add all diff lines directly + for i := range d.Lines { + d.displayRows = append(d.displayRows, diffDisplayRow{kind: diffRowLine, lineIdx: i}) + } + return + } + + // Build a map: right-side line number -> list of comments + commentsByLine := make(map[int][]github.PRComment) + for _, c := range d.Comments { + if c.Line > 0 { + commentsByLine[c.Line] = append(commentsByLine[c.Line], c) + } + } + + for i, dl := range d.Lines { + d.displayRows = append(d.displayRows, diffDisplayRow{kind: diffRowLine, lineIdx: i}) + + // Check if there are comments for this line (by right-side line number) + rightNum := dl.Right.Num + if comments, ok := commentsByLine[rightNum]; ok && rightNum > 0 { + for ci := range comments { + comment := &comments[ci] + // Header row: @user - date + header := fmt.Sprintf(" @%s %s", comment.User, github.FormatCommentTime(comment.CreatedAt)) + d.displayRows = append(d.displayRows, diffDisplayRow{ + kind: diffRowComment, + lineIdx: i, + comment: comment, + commentLine: 0, + commentText: header, + }) + // Body rows: wrap comment body + bodyLines := strings.Split(comment.Body, "\n") + for li, bl := range bodyLines { + d.displayRows = append(d.displayRows, diffDisplayRow{ + kind: diffRowComment, + lineIdx: i, + comment: comment, + commentLine: li + 1, + commentText: " " + bl, + }) + } + } + } + } } func (d *DiffViewWidget) Focusable() bool { return true } @@ -179,16 +259,27 @@ func (d *DiffViewWidget) ApplySearchHighlight(query string, opts SearchOptions) } func (d *DiffViewWidget) ScrollToLine(line int) { + // Map line index to display row index + rowIdx := line + if len(d.displayRows) > 0 { + for i, row := range d.displayRows { + if row.kind == diffRowLine && row.lineIdx >= line { + rowIdx = i + break + } + } + } + if d.viewH <= 0 { - d.TopLine = line + d.TopLine = rowIdx return } - if line < d.TopLine || line >= d.TopLine+d.viewH { - d.TopLine = line - d.viewH/2 + if rowIdx < d.TopLine || rowIdx >= d.TopLine+d.viewH { + d.TopLine = rowIdx - d.viewH/2 if d.TopLine < 0 { d.TopLine = 0 } - max := len(d.Lines) - d.viewH + max := len(d.displayRows) - d.viewH if max < 0 { max = 0 } @@ -285,8 +376,9 @@ func (d *DiffViewWidget) Render(surface *RenderSurface) { gutterW := d.gutterWidth() - showVScroll := len(d.Lines) > h - contentW := (w - 1) / 2 - gutterW + totalRows := len(d.displayRows) + showVScroll := totalRows > h + contentW := (w-1)/2 - gutterW showHScroll := d.maxLineW > contentW if showHScroll { @@ -317,10 +409,10 @@ func (d *DiffViewWidget) Render(surface *RenderSurface) { } for y := 0; y < h; y++ { - idx := d.TopLine + y + rowIdx := d.TopLine + y surface.SetCell(dividerX, y, term.Cell{Ch: '│', Style: term.StyleBorder}) - if idx >= len(d.Lines) { + if rowIdx >= totalRows { for x := 0; x < dividerX; x++ { surface.SetCell(x, y, term.Cell{Ch: ' '}) } @@ -330,6 +422,18 @@ func (d *DiffViewWidget) Render(surface *RenderSurface) { continue } + row := d.displayRows[rowIdx] + + if row.kind == diffRowComment { + d.renderCommentRow(surface, y, w, dividerX, gutterW, row) + continue + } + + idx := row.lineIdx + if idx >= len(d.Lines) { + continue + } + dl := d.Lines[idx] leftStyle := kindToStyle(dl.Left.Kind) @@ -363,7 +467,7 @@ func (d *DiffViewWidget) Render(surface *RenderSurface) { d.scrollbar.X = r.X + w d.scrollbar.Y = r.Y d.scrollbar.Height = h - d.scrollbar.TotalItems = len(d.Lines) + d.scrollbar.TotalItems = totalRows d.scrollbar.TopItem = d.TopLine d.scrollbar.Render(surface, w, 0) } @@ -407,6 +511,43 @@ func (d *DiffViewWidget) renderGutter(surface *RenderSurface, x, y, w int, sl di } } +func (d *DiffViewWidget) renderCommentRow(surface *RenderSurface, y, totalW, dividerX, gutterW int, row diffDisplayRow) { + // Fill background with comment style across the full width + bgStyle := term.StyleCommentBg + for x := 0; x < totalW; x++ { + surface.SetCell(x, y, term.Cell{Ch: ' ', Style: bgStyle}) + } + + // Render comment text spanning the full width (no gutter/divider for comment rows) + text := row.commentText + textStyle := bgStyle + if row.commentLine == 0 { + // Header row: use author style + textStyle = term.StyleCommentAuthor + } + + runes := []rune(text) + for i, ch := range runes { + if i >= totalW { + break + } + style := textStyle + if row.commentLine == 0 && i >= 2 { + // After the initial " @user" part, switch to muted style for the date + userEnd := 2 // " " + user := "@" + row.comment.User + userEnd += len([]rune(user)) + if i >= userEnd { + style = term.StyleMuted + // Override bg to match comment bg + surface.SetCell(i, y, term.Cell{Ch: ch, Style: style, BgStyle: bgStyle}) + continue + } + } + surface.SetCell(i, y, term.Cell{Ch: ch, Style: style, BgStyle: bgStyle}) + } +} + func (d *DiffViewWidget) renderSide(surface *RenderSurface, x, y, w int, text string, baseStyle term.Style, spans []highlight.Span, lineIdx int, matches []FindMatch, activeIdx int, selSide bool) { runes := []rune(text) for i := 0; i < w; i++ { @@ -485,7 +626,18 @@ func (d *DiffViewWidget) screenToSel(mx, my int) (pos diffSelPos, right bool, ok r := d.GetRect() localX := mx - r.X localY := my - r.Y - line := d.TopLine + localY + rowIdx := d.TopLine + localY + + // Check if this row is a comment row (not selectable) + if rowIdx >= 0 && rowIdx < len(d.displayRows) && d.displayRows[rowIdx].kind == diffRowComment { + return diffSelPos{}, false, false + } + + // Map display row index back to diff line index + line := rowIdx + if rowIdx >= 0 && rowIdx < len(d.displayRows) { + line = d.displayRows[rowIdx].lineIdx + } if localX >= d.layoutLeftStart && localX < d.layoutLeftStart+d.layoutLeftW { col := d.LeftCol + (localX - d.layoutLeftStart) @@ -628,7 +780,7 @@ func (d *DiffViewWidget) HandleEvent(ev tcell.Event) EventResult { } return EventConsumed case tcell.KeyDown: - max := len(d.Lines) - d.viewH + max := len(d.displayRows) - d.viewH if max < 0 { max = 0 } @@ -652,7 +804,7 @@ func (d *DiffViewWidget) HandleEvent(ev tcell.Event) EventResult { } return EventConsumed case tcell.KeyPgDn: - max := len(d.Lines) - d.viewH + max := len(d.displayRows) - d.viewH if max < 0 { max = 0 } @@ -666,7 +818,7 @@ func (d *DiffViewWidget) HandleEvent(ev tcell.Event) EventResult { d.LeftCol = 0 return EventConsumed case tcell.KeyEnd: - max := len(d.Lines) - d.viewH + max := len(d.displayRows) - d.viewH if max < 0 { max = 0 } @@ -695,7 +847,7 @@ func (d *DiffViewWidget) HandleEvent(ev tcell.Event) EventResult { d.LeftCol += 4 d.clampLeftCol() } else { - max := len(d.Lines) - d.viewH + max := len(d.displayRows) - d.viewH if max < 0 { max = 0 } diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index 63a63dbf..8aedb6e5 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -60,27 +60,27 @@ type editorTab struct { type EditorGroupWidget struct { BaseWidget - TabBar *TabBarWidget - Editor *EditorPaneWidget - Autocomplete *AutocompleteWidget - Hover *HoverWidget - SignatureHelp *SignatureHelpWidget - tabs []editorTab - active int - TabSize int - InsertSpaces bool - LineNumbers bool + TabBar *TabBarWidget + Editor *EditorPaneWidget + Autocomplete *AutocompleteWidget + Hover *HoverWidget + SignatureHelp *SignatureHelpWidget + tabs []editorTab + active int + TabSize int + InsertSpaces bool + LineNumbers bool GutterStyle string WordWrap bool BracketPairColorization bool BracketColorStyles []term.Style InsertFinalNewline bool - TrimTrailingWhitespace bool - Borders *term.BorderSet - OnFileOpen func(path, lang, text string) - OnFileChange func(path, lang, text string) - OnFileClose func(path, lang string) - OnError func(msg string) + TrimTrailingWhitespace bool + Borders *term.BorderSet + OnFileOpen func(path, lang, text string) + OnFileChange func(path, lang, text string) + OnFileClose func(path, lang string) + OnError func(msg string) } func NewEditorGroupWidget(borders *term.BorderSet, tabSize int, lineNumbers bool, gutterStyle string) *EditorGroupWidget { diff --git a/internal/ui/explorer_widget.go b/internal/ui/explorer_widget.go index 76b14aac..42f240cb 100644 --- a/internal/ui/explorer_widget.go +++ b/internal/ui/explorer_widget.go @@ -26,10 +26,10 @@ type TreeNode struct { type ExplorerWidget struct { BaseWidget SelectableList - Roots []*TreeNode - FlatList []*TreeNode - ActiveFile string - Settings config.ExplorerSettings + Roots []*TreeNode + FlatList []*TreeNode + ActiveFile string + Settings config.ExplorerSettings OnOpenFile func(path string) OnRightClick func(node *TreeNode, screenX, screenY int) } @@ -285,7 +285,6 @@ func (e *ExplorerWidget) ActivateSelected() { } } - func (e *ExplorerWidget) collapseSelected() { if e.Selected < 0 || e.Selected >= len(e.FlatList) { return diff --git a/internal/ui/input_widget.go b/internal/ui/input_widget.go index 0ec1ffbd..6e7215ca 100644 --- a/internal/ui/input_widget.go +++ b/internal/ui/input_widget.go @@ -498,7 +498,6 @@ func (inp *InputWidget) Clear() { inp.notify() } - func (inp *InputWidget) notify() { if inp.OnChange != nil { inp.OnChange(inp.Text) diff --git a/internal/ui/keybindings_widget.go b/internal/ui/keybindings_widget.go index 1aac787e..ebece782 100644 --- a/internal/ui/keybindings_widget.go +++ b/internal/ui/keybindings_widget.go @@ -27,9 +27,9 @@ type KeybindingsWidget struct { selected int scrollOffset int - recording bool - recordCombo string - recordChord string + recording bool + recordCombo string + recordChord string focusedAction int // -1 = input/list, 0..4 = footer buttons boxX, boxY, boxW, boxH int diff --git a/internal/ui/menubar_widget.go b/internal/ui/menubar_widget.go index 5343ed3e..8ea67a9b 100644 --- a/internal/ui/menubar_widget.go +++ b/internal/ui/menubar_widget.go @@ -12,9 +12,9 @@ type MenuItem struct { type MenuBarWidget struct { BaseWidget - Items []MenuItem - Selected int - OnSelect func(index int) + Items []MenuItem + Selected int + OnSelect func(index int) itemSpans []struct{ start, end int } } diff --git a/internal/ui/scrollbar.go b/internal/ui/scrollbar.go index da47e3ca..d2f01d78 100644 --- a/internal/ui/scrollbar.go +++ b/internal/ui/scrollbar.go @@ -113,12 +113,12 @@ func (s *Scrollbar) posToTopItem(thumbTop int) int { } type HScrollbar struct { - X int - Y int - Width int - TotalCols int - LeftCol int - dragging bool + X int + Y int + Width int + TotalCols int + LeftCol int + dragging bool dragOffset int } diff --git a/internal/ui/search_options.go b/internal/ui/search_options.go index 0fe8c855..97dc9642 100644 --- a/internal/ui/search_options.go +++ b/internal/ui/search_options.go @@ -45,7 +45,7 @@ func findPlain(lines []string, query string, opts SearchOptions) []FindMatch { } bytePos := offset + idx col := len([]rune(searchLine[:bytePos])) - matches = append(matches, FindMatch{Line: lineIdx, Col: col, Len: queryLen}) + matches = append(matches, FindMatch{Line: lineIdx, Col: col, Len: queryLen}) offset = bytePos + len(searchQuery) } } @@ -74,7 +74,6 @@ func findRegex(lines []string, query string, opts SearchOptions) ([]FindMatch, e return matches, nil } - type searchMatchByPos []SearchMatch func (s searchMatchByPos) Len() int { return len(s) } diff --git a/internal/ui/selectable_list.go b/internal/ui/selectable_list.go index 617d2cbb..132c2949 100644 --- a/internal/ui/selectable_list.go +++ b/internal/ui/selectable_list.go @@ -5,9 +5,9 @@ import "github.com/gdamore/tcell/v2" type ListAction int const ( - ListActionNone ListAction = iota - ListActionActivate // click or Enter - ListActionContext // right-click + ListActionNone ListAction = iota + ListActionActivate // click or Enter + ListActionContext // right-click ) type ListEventResult struct { diff --git a/internal/ui/sidebar_widget.go b/internal/ui/sidebar_widget.go index 592dbc03..de85fc6c 100644 --- a/internal/ui/sidebar_widget.go +++ b/internal/ui/sidebar_widget.go @@ -9,10 +9,10 @@ import ( type SidebarWidget struct { BaseWidget TabbedPanel - Visible bool - MoreButton *MoreButtonWidget - OnSwitch func(id string) - OnTabOverflow func(hiddenIDs []string, hiddenTitles []string, screenX, screenY int) + Visible bool + MoreButton *MoreButtonWidget + OnSwitch func(id string) + OnTabOverflow func(hiddenIDs []string, hiddenTitles []string, screenX, screenY int) } func NewSidebarWidget() *SidebarWidget { diff --git a/internal/ui/signature_widget.go b/internal/ui/signature_widget.go index 7290fef5..55790e3f 100644 --- a/internal/ui/signature_widget.go +++ b/internal/ui/signature_widget.go @@ -8,12 +8,12 @@ import ( type SignatureHelpWidget struct { BaseWidget - Label string + Label string ActiveParamStart int ActiveParamEnd int - AnchorX int - AnchorY int - Borders *term.BorderSet + AnchorX int + AnchorY int + Borders *term.BorderSet } func NewSignatureHelpWidget(label string, paramStart, paramEnd int) *SignatureHelpWidget { diff --git a/internal/ui/split_panel.go b/internal/ui/split_panel.go index 853a7afb..0dd80ccb 100644 --- a/internal/ui/split_panel.go +++ b/internal/ui/split_panel.go @@ -1,8 +1,8 @@ package ui import ( - "log/slog" "github.com/eugenioenko/ttt/internal/term" + "log/slog" "github.com/gdamore/tcell/v2" ) diff --git a/internal/ui/statusbar_widget.go b/internal/ui/statusbar_widget.go index ec55ca08..79b96309 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 { diff --git a/internal/ui/tabbar_widget.go b/internal/ui/tabbar_widget.go index c0716383..050f8f2f 100644 --- a/internal/ui/tabbar_widget.go +++ b/internal/ui/tabbar_widget.go @@ -1,9 +1,9 @@ package ui import ( + "github.com/eugenioenko/ttt/internal/term" "log/slog" "path/filepath" - "github.com/eugenioenko/ttt/internal/term" "github.com/gdamore/tcell/v2" ) @@ -23,9 +23,9 @@ type Tab struct { type TabBarWidget struct { BaseWidget - Tabs []Tab - Borders *term.BorderSet - ScrollOffset int + Tabs []Tab + Borders *term.BorderSet + ScrollOffset int MoreButton *MoreButtonWidget OnTabClick func(index int) OnTabClose func(index int) diff --git a/internal/ui/terminal_widget.go b/internal/ui/terminal_widget.go index 6ced87fb..4cf337ff 100644 --- a/internal/ui/terminal_widget.go +++ b/internal/ui/terminal_widget.go @@ -9,14 +9,14 @@ import ( "github.com/eugenioenko/ttt/internal/term" "github.com/eugenioenko/ttt/internal/terminal" - "github.com/gdamore/tcell/v2" "github.com/eugenioenko/vt10x" + "github.com/gdamore/tcell/v2" ) type TerminalColorPalette struct { - Fg term.DirectColor - Bg term.DirectColor - ANSI [16]term.DirectColor + Fg term.DirectColor + Bg term.DirectColor + ANSI [16]term.DirectColor Color256 [256]term.DirectColor } diff --git a/internal/ui/widget.go b/internal/ui/widget.go index 63d02ada..e3f86dfc 100644 --- a/internal/ui/widget.go +++ b/internal/ui/widget.go @@ -9,7 +9,7 @@ type Rect struct { type EventResult int const ( - EventIgnored EventResult = iota + EventIgnored EventResult = iota EventConsumed EventDismissed EventCaptured @@ -18,7 +18,7 @@ const ( type ConstraintType int const ( - Fixed ConstraintType = iota + Fixed ConstraintType = iota Flex Hidden ) diff --git a/internal/view/statusbar.go b/internal/view/statusbar.go index 721df4be..47468b41 100644 --- a/internal/view/statusbar.go +++ b/internal/view/statusbar.go @@ -26,21 +26,21 @@ 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 + Notification string + NotifyLevel NotifyLevel + NotifyExpiry time.Time NotifyAction func() ActionLabel string SecondaryAction func() diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go index 92f873fc..0ab43c75 100644 --- a/tests/e2e/harness_test.go +++ b/tests/e2e/harness_test.go @@ -223,7 +223,7 @@ func (h *testHarness) stop() { type emptyWidget struct{ ui.BaseWidget } -func newEmptyWidget() *emptyWidget { return &emptyWidget{} } -func (e *emptyWidget) Focusable() bool { return false } -func (e *emptyWidget) Render(surface *ui.RenderSurface) {} +func newEmptyWidget() *emptyWidget { return &emptyWidget{} } +func (e *emptyWidget) Focusable() bool { return false } +func (e *emptyWidget) Render(surface *ui.RenderSurface) {} func (e *emptyWidget) HandleEvent(ev tcell.Event) ui.EventResult { return ui.EventIgnored }