package main import ( "context" "fmt" "hash/fnv" "slices" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) type screen int const ( prScreen screen = iota dashboardScreen threadScreen ) type pane int const ( threadListPane pane = iota threadDetailPane ) type tickMsg time.Time type pathTickMsg time.Time type prsLoadedMsg struct { prs []PullRequest err error cached bool } type detailsLoadedMsg struct { owner string repo string number int details PRDetails err error cached bool } type App struct { service GitHubService owner, repo string showAll bool limit int poll time.Duration screen screen prs []PullRequest prIndex int details PRDetails threadIndex int folded map[string]bool focus pane listHidden bool scroll int width, height int loading bool err error lastRefresh time.Time pendingZ bool searching bool searchQuery string searchOrigin int helpVisible bool helpScroll int foldResolved bool threadListWidthPercent int pathScroll bool pathScrollInterval time.Duration pathScrollStep int threadStatusOrder []string threadWithinStatus string dashboardMode string dashboardReturn screen compactReviews bool readState *readStateStore knownThreads map[string]bool knownComments map[string]bool initializedPRs map[string]bool unreadThreads map[string]bool updatedThreads map[string]bool } type AppSettings struct { FoldResolved bool ThreadListWidthPercent int PathScroll bool PathScrollInterval time.Duration ThreadStatusOrder []string ThreadWithinStatus string DashboardMode string CompactReviews bool ReadState *readStateStore } func defaultAppSettings() AppSettings { return AppSettings{ FoldResolved: true, ThreadListWidthPercent: 33, PathScrollInterval: 350 * time.Millisecond, ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"}, ThreadWithinStatus: "file", DashboardMode: "hotkey", CompactReviews: true, } } func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App { return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings()) } func NewAppWithSettings( service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration, settings AppSettings, ) App { state := settings.ReadState if state == nil { state = &readStateStore{Data: make(map[string]readPRState)} } return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent, pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...), threadWithinStatus: settings.ThreadWithinStatus, dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, compactReviews: settings.CompactReviews, readState: state, knownThreads: make(map[string]bool), knownComments: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), updatedThreads: make(map[string]bool), } } func (m App) Init() tea.Cmd { commands := []tea.Cmd{m.loadPRs(true), m.nextTick()} if m.pathScroll { commands = append(commands, m.nextPathTick()) } return tea.Batch(commands...) } func (m App) nextTick() tea.Cmd { return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m App) nextPathTick() tea.Cmd { return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) }) } func (m App) loadPRs(useCache bool) tea.Cmd { commands := []tea.Cmd{m.loadLivePRs()} if cache, ok := m.service.(cachedSnapshotService); ok && useCache { commands = append([]tea.Cmd{func() tea.Msg { prs, err := cache.CachedPullRequests(m.owner, m.repo, m.limit, m.showAll) return prsLoadedMsg{prs: prs, err: err, cached: true} }}, commands...) } return tea.Batch(commands...) } func (m App) loadLivePRs() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() var prs []PullRequest var err error if live, ok := m.service.(liveGitHubService); ok { prs, err = live.LivePullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) } else { prs, err = m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) } return prsLoadedMsg{prs: prs, err: err} } } func (m App) loadDetails(pr PullRequest, useCache bool) tea.Cmd { commands := []tea.Cmd{m.loadLiveDetails(pr)} if cache, ok := m.service.(cachedSnapshotService); ok && useCache { commands = append([]tea.Cmd{func() tea.Msg { details, err := cache.CachedPullRequest(pr.Owner, pr.Repository, pr.Number) return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, cached: true, } }}, commands...) } return tea.Batch(commands...) } func (m App) loadLiveDetails(pr PullRequest) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() var details PRDetails var err error if live, ok := m.service.(liveGitHubService); ok { details, err = live.LivePullRequest(ctx, pr.Owner, pr.Repository, pr.Number) } else { details, err = m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number) } return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, } } } func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tickMsg: if !m.loading { m.loading = true if (m.screen == dashboardScreen || m.screen == threadScreen) && m.details.Number != 0 { return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick()) } return m, tea.Batch(m.loadPRs(false), m.nextTick()) } return m, m.nextTick() case pathTickMsg: if m.pathScroll && m.screen == threadScreen { m.pathScrollStep++ } if m.pathScroll { return m, m.nextPathTick() } return m, nil case prsLoadedMsg: if m.screen != prScreen || msg.cached && !m.loading { return m, nil } if !msg.cached { m.loading = false } if msg.err != nil { if msg.cached { return m, nil } m.err = msg.err return m, nil } selected := "" if len(m.prs) > 0 && m.prIndex < len(m.prs) { selected = m.prs[m.prIndex].ID } m.prs = msg.prs sort.SliceStable(m.prs, func(i, j int) bool { left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner) if left != right { return left < right } return m.prs[i].UpdatedAt.After(m.prs[j].UpdatedAt) }) m.prIndex = indexPR(m.prs, selected) m.err = nil if msg.cached && len(msg.prs) > 0 { m.lastRefresh = msg.prs[0].CachedAt } else { m.lastRefresh = time.Now() } case detailsLoadedMsg: if m.screen != dashboardScreen && m.screen != threadScreen { return m, nil } if msg.cached && !m.loading { return m, nil } if !msg.cached { m.loading = false } if m.details.Number != 0 && (msg.number != m.details.Number || msg.owner != m.details.Owner || msg.repo != m.details.Repository) { return m, nil } if msg.err != nil { if msg.cached { return m, nil } m.err = msg.err return m, nil } selected := "" anchor := "" if m.threadIndex < len(m.details.Threads) { selected = m.details.Threads[m.threadIndex].ID anchor = m.detailScrollAnchor() } m.trackThreadUpdates(msg.details) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details m.threadIndex = indexThread(m.details.Threads, selected) if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) { m.scroll = 0 } for _, thread := range m.details.Threads { if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved { m.folded[thread.ID] = true } } if m.screen == dashboardScreen { m.scroll = min(m.scroll, m.dashboardMaxScroll()) } else { if anchor != "" { m.restoreDetailAnchor(anchor) } else { m.scroll = min(m.scroll, m.detailMaxScroll()) } } m.err = nil if msg.cached { m.lastRefresh = msg.details.CachedAt } else { m.lastRefresh = time.Now() } } key, ok := msg.(tea.KeyMsg) if !ok { return m, nil } k := key.String() if m.helpVisible { switch k { case "ctrl+c": return m, tea.Quit case "?", "esc", "q": m.helpVisible, m.helpScroll = false, 0 case "j", "down": m.helpScroll = min(m.helpScroll+1, m.helpMaxScroll()) case "k", "up": m.helpScroll = max(0, m.helpScroll-1) case "g": m.helpScroll = 0 case "G": m.helpScroll = m.helpMaxScroll() case "ctrl+d", "pgdown": m.helpScroll = min(m.helpScroll+max(3, m.height/2), m.helpMaxScroll()) case "ctrl+u", "pgup": m.helpScroll = max(0, m.helpScroll-max(3, m.height/2)) } return m, nil } if m.searching { switch k { case "ctrl+c": return m, tea.Quit case "esc": m.searching, m.searchQuery = false, "" m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 case "enter": m.searching = false case "up": m.moveSearch(-1) case "down", "tab": m.moveSearch(1) case "backspace": runes := []rune(m.searchQuery) if len(runes) > 0 { m.searchQuery = string(runes[:len(runes)-1]) m.selectBestSearchMatch() } case "ctrl+u": m.searchQuery = "" m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 default: if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { m.searchQuery += string(key.Runes) m.selectBestSearchMatch() } } return m, nil } if k == "ctrl+c" || k == "q" { return m, tea.Quit } if k == "?" { m.helpVisible, m.helpScroll = true, 0 return m, nil } if m.pendingZ { m.pendingZ = false if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 } return m, nil } if k == "z" && m.screen == threadScreen { m.pendingZ = true return m, nil } switch k { case "F": if m.screen == threadScreen { m.searchQuery = "" m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1) } case "/": if m.screen == threadScreen { m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex m.focus, m.listHidden, m.scroll = threadListPane, false, 0 } case "r": if m.loading { return m, nil } m.loading = true if m.screen == dashboardScreen || m.screen == threadScreen { return m, m.loadDetails(m.details.PullRequest, false) } return m, m.loadPRs(false) case "j", "down": if m.screen == dashboardScreen { m.scrollDashboard(1) } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(1) } else { m.move(1) } case "k", "up": if m.screen == dashboardScreen { m.scrollDashboard(-1) } else if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(-1) } else { m.move(-1) } case "g": m.toStart() case "G": m.toEnd() case "tab": if m.screen == threadScreen { m.listHidden = !m.listHidden if m.listHidden { m.focus = threadDetailPane } else { m.focus = threadListPane } m.scroll = 0 } case "ctrl+d", "pgdown": m.page(1) case "ctrl+u", "pgup": m.page(-1) case "n": if m.screen == threadScreen { m.moveToUnread(1) } case "N": if m.screen == threadScreen { m.moveToUnread(-1) } case "d": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(dashboardScreen) } if m.screen == threadScreen { m.dashboardReturn, m.screen, m.scroll = threadScreen, dashboardScreen, 0 return m, nil } case "l": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(m.defaultPRTargetScreen()) } if m.screen == dashboardScreen { m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false return m, nil } if m.screen == threadScreen { m.focus = threadDetailPane m.markCurrentThreadRead() } case "h": if m.screen == threadScreen { m.focus = threadListPane m.listHidden = false } case "enter": if m.screen == prScreen && len(m.prs) > 0 { return m, m.openSelectedPR(m.defaultPRTargetScreen()) } if m.screen == dashboardScreen { m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false return m, nil } if m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.markCurrentThreadRead() m.scroll = 0 } case "b", "esc": if m.screen == threadScreen { if m.dashboardMode == "intermediate" { m.dashboardReturn, m.screen, m.scroll, m.err = prScreen, dashboardScreen, 0, nil return m, nil } m.screen, m.err, m.loading = prScreen, nil, true return m, m.loadPRs(false) } if m.screen == dashboardScreen { m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil if m.screen == prScreen { m.loading = true m.searching, m.searchQuery = false, "" return m, m.loadPRs(false) } return m, nil } } return m, nil } func (m App) defaultPRTargetScreen() screen { if m.dashboardMode == "hotkey" { return threadScreen } return dashboardScreen } func (m *App) openSelectedPR(target screen) tea.Cmd { m.screen, m.dashboardReturn = target, prScreen m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 return m.loadDetails(m.details.PullRequest, true) } func (m *App) trackThreadUpdates(details PRDetails) { if m.knownThreads == nil { m.knownThreads = make(map[string]bool) m.knownComments = make(map[string]bool) m.initializedPRs = make(map[string]bool) m.unreadThreads = make(map[string]bool) } m.updatedThreads = make(map[string]bool) prID := details.ID if prID == "" { prID = fmt.Sprintf("%s#%d", details.RepoWithOwner, details.Number) } state := m.readState.Data[prID] if state.Threads == nil { state.Threads = make(map[string]bool) state.Comments = make(map[string]bool) } if !state.Initialized { for _, thread := range details.Threads { state.Threads[thread.ID] = true for _, comment := range thread.Comments { state.Comments[comment.ID] = true } } state.Initialized = true m.readState.Data[prID] = state _ = m.readState.save() return } for _, thread := range details.Threads { updated := !state.Threads[thread.ID] for _, comment := range thread.Comments { if !state.Comments[comment.ID] { updated = true } m.knownComments[comment.ID] = true } m.knownThreads[thread.ID] = true if updated { m.unreadThreads[thread.ID] = true m.updatedThreads[thread.ID] = true } } m.initializedPRs[prID] = true } func (m *App) markCurrentThreadRead() { if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) { thread := m.details.Threads[m.threadIndex] delete(m.unreadThreads, thread.ID) prID := m.currentPRKey() state := m.readState.Data[prID] if state.Threads == nil { state.Threads = make(map[string]bool) state.Comments = make(map[string]bool) } state.Initialized = true state.Threads[thread.ID] = true for _, comment := range thread.Comments { state.Comments[comment.ID] = true } m.readState.Data[prID] = state _ = m.readState.save() } } func (m App) currentPRKey() string { if m.details.ID != "" { return m.details.ID } return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number) } func (m *App) moveToUnread(direction int) { count := len(m.details.Threads) if count == 0 { return } for offset := 1; offset <= count; offset++ { index := (m.threadIndex + direction*offset) % count if index < 0 { index += count } if m.unreadThreads[m.details.Threads[index].ID] { m.threadIndex, m.scroll = index, 0 m.markCurrentThreadRead() return } } } func (m *App) move(delta int) { if m.screen == prScreen { m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) return } if m.searchQuery != "" { matches := m.matchingThreadIndices() if len(matches) == 0 { return } position := slices.Index(matches, m.threadIndex) if position < 0 { position = 0 } m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)] m.scroll = 0 m.markCurrentThreadRead() return } m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1) m.scroll = 0 m.markCurrentThreadRead() } func (m *App) moveSearch(delta int) { matches := m.matchingThreadIndices() if len(matches) == 0 { return } position := 0 for i, index := range matches { if index == m.threadIndex { position = i break } } position = clamp(position+delta, 0, len(matches)-1) m.threadIndex = matches[position] m.scroll = 0 } func (m *App) selectBestSearchMatch() { matches := m.matchingThreadIndices() if len(matches) > 0 { m.threadIndex = matches[0] m.scroll = 0 } } func (m App) matchingThreadIndices() []int { if m.searchQuery == "" { indices := make([]int, len(m.details.Threads)) for i := range m.details.Threads { indices[i] = i } return indices } type match struct { index int score int } filter := parseThreadFilter(m.searchQuery) matches := make([]match, 0, len(m.details.Threads)) for i, thread := range m.details.Threads { if filter.status != "" && threadStatus(thread) != filter.status { continue } if filter.updated && !m.updatedThreads[thread.ID] { continue } if filter.author != "" && !threadHasAuthor(thread, filter.author) { continue } if score, ok := fuzzyPathScore(thread.Path, filter.path); ok { matches = append(matches, match{index: i, score: score}) } } sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) indices := make([]int, len(matches)) for i, match := range matches { indices[i] = match.index } return indices } type threadFilter struct { path string status string author string updated bool } func parseThreadFilter(query string) threadFilter { var filter threadFilter var pathTerms []string for _, term := range strings.Fields(query) { key, value, found := strings.Cut(term, ":") if !found { pathTerms = append(pathTerms, term) continue } switch strings.ToLower(key) { case "status": value = strings.ToLower(value) if value == "open" { value = "unresolved" } if value == "unresolved" || value == "outdated" || value == "resolved" { filter.status = value } case "author": filter.author = strings.TrimPrefix(strings.ToLower(value), "@") case "updated", "new": filter.updated = value == "" || value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") default: pathTerms = append(pathTerms, term) } } filter.path = strings.Join(pathTerms, " ") return filter } func threadHasAuthor(thread ReviewThread, author string) bool { for _, comment := range thread.Comments { if strings.Contains(strings.ToLower(comment.Author), author) { return true } } return false } func sortReviewThreads(threads []ReviewThread, statusOrder []string, withinStatus string) { ranks := map[string]int{ "unresolved": 0, "outdated": 1, "resolved": 2, } for rank, status := range statusOrder { ranks[status] = rank } if withinStatus == "" { withinStatus = "file" } sort.SliceStable(threads, func(i, j int) bool { left, right := threads[i], threads[j] leftRank, rightRank := ranks[threadStatus(left)], ranks[threadStatus(right)] if leftRank != rightRank { return leftRank < rightRank } if withinStatus == "timestamp" { if less, decided := compareThreadTimestamps(left, right); decided { return less } } leftPath, rightPath := strings.ToLower(left.Path), strings.ToLower(right.Path) if leftPath != rightPath { return leftPath < rightPath } if left.Line != right.Line { return left.Line < right.Line } if withinStatus != "timestamp" { if less, decided := compareThreadTimestamps(left, right); decided { return less } } return false }) } func threadStatus(thread ReviewThread) string { if thread.IsResolved { return "resolved" } if thread.IsOutdated { return "outdated" } return "unresolved" } func threadOpenedAt(thread ReviewThread) time.Time { var opened time.Time for _, comment := range thread.Comments { if comment.CreatedAt.IsZero() || (!opened.IsZero() && !comment.CreatedAt.Before(opened)) { continue } opened = comment.CreatedAt } return opened } func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) { leftTime, rightTime := threadOpenedAt(left), threadOpenedAt(right) switch { case leftTime.IsZero() && rightTime.IsZero(): return false, false case leftTime.IsZero(): return false, true case rightTime.IsZero(): return true, true case !leftTime.Equal(rightTime): return leftTime.Before(rightTime), true default: return false, false } } func (m *App) toStart() { if m.screen == prScreen { m.prIndex = 0 } else if m.screen == dashboardScreen { m.scroll = 0 } else if m.focus == threadDetailPane { m.scroll = 0 } else { m.threadIndex, m.scroll = 0, 0 } } func (m *App) toEnd() { if m.screen == prScreen { m.prIndex = max(0, len(m.prs)-1) } else if m.screen == dashboardScreen { m.scroll = m.dashboardMaxScroll() } else if m.focus == threadDetailPane { m.scroll = m.detailMaxScroll() } else { m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0 } } func (m *App) page(direction int) { if m.screen == dashboardScreen { m.scrollDashboard(direction * max(3, m.dashboardViewportHeight()/2)) return } if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(direction * max(3, m.detailViewportHeight()/2)) return } m.move(direction * max(3, m.height/2)) } func (m *App) scrollDetail(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) } func (m *App) scrollDashboard(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll()) } func (m App) dashboardViewportHeight() int { return max(1, m.height-2) } func (m App) dashboardMaxScroll() int { return max(0, len(m.dashboardLines())-m.dashboardViewportHeight()) } func (m App) detailPaneSize() (int, int) { topLines := 3 if m.details.ThreadsTruncated { topLines++ } height := max(3, m.height-topLines-1) if m.width < 70 || m.listHidden { return max(3, m.width), height } leftWidth := m.threadListWidth() return max(20, m.width-leftWidth-1), height } func (m App) threadListWidth() int { percent := m.threadListWidthPercent if percent == 0 { percent = 33 } return clamp(m.width*percent/100, 30, max(30, m.width-20)) } func (m App) detailViewportHeight() int { _, height := m.detailPaneSize() return max(1, height-2) } func (m App) detailMaxScroll() int { width, _ := m.detailPaneSize() return max(0, len(m.detailLines(width))-m.detailViewportHeight()) } func (m App) View() string { if m.width == 0 { return "Loading…" } if m.helpVisible { return m.viewHelp() } if m.screen == prScreen { return m.viewPRs() } if m.screen == dashboardScreen { return m.viewDashboard() } return m.viewThreads() } type helpBinding struct { key string action string } func (m App) helpBindings() []helpBinding { if m.screen == prScreen { openAction := "Open pull request dashboard" if m.dashboardMode == "hotkey" { openAction = "Open review threads" } return []helpBinding{ {"j / ↓", "Next pull request"}, {"k / ↑", "Previous pull request"}, {"g / G", "First / last pull request"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"enter / l", openAction}, {"d", "Open pull request dashboard"}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, } } if m.screen == dashboardScreen { backAction := "Return to pull requests" if m.dashboardReturn == threadScreen { backAction = "Return to review threads" } return []helpBinding{ {"j / ↓", "Scroll description down"}, {"k / ↑", "Scroll description up"}, {"g / G", "Top / bottom"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"enter / l", "Open review threads"}, {"b / esc", backAction}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, } } backAction := "Return to pull requests" if m.dashboardMode == "intermediate" { backAction = "Return to PR dashboard" } bindings := []helpBinding{ {"h / l", "Focus thread list / detail"}, {"j / k", "Move or scroll focused pane"}, {"↓ / ↑", "Move or scroll focused pane"}, {"g / G", "First / last item"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"tab", "Hide / reveal thread list"}, {"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, {"F", "Clear the active thread filter and show every thread"}, {"n / N", "Next / previous new update"}, {"d", "Open pull request dashboard"}, {"enter / za", "Fold / expand thread"}, {"b / esc", backAction}, {"r", "Refresh now"}, } for _, item := range writeCapabilities(m.details, m.selectedThread()) { state := item.reason if item.authorized { state = "ready; " + item.reason } bindings = append(bindings, helpBinding{"write", item.name + ": " + state}) } bindings = append(bindings, helpBinding{"?", "Close this help"}, helpBinding{"q / ctrl-c", "Quit"}, ) return bindings } func (m App) helpVisibleRows() int { return max(1, m.height-5) } func (m App) helpMaxScroll() int { return max(0, len(m.helpRows(m.helpContentWidth()))-m.helpVisibleRows()) } func (m App) viewHelp() string { contentWidth := m.helpContentWidth() rows := m.helpRows(contentWidth) visibleRows := m.helpVisibleRows() start := clamp(m.helpScroll, 0, max(0, len(rows)-visibleRows)) end := min(len(rows), start+visibleRows) title := "Pull request picker keys" if m.screen == dashboardScreen { title = "Pull request dashboard keys" } else if m.screen == threadScreen { title = "Review thread keys" } lines := []string{titleStyle.Render(title)} lines = append(lines, rows[start:end]...) if len(rows) > visibleRows { lines = append(lines, dimStyle.Render(fmt.Sprintf( "%d–%d of %d • j/k scroll • ?/esc/q close", start+1, end, len(rows), ))) } else { lines = append(lines, dimStyle.Render("?/esc/q close")) } for i := range lines { lines[i] = ansi.Truncate(lines[i], contentWidth, "") } popup := paneStyle(true).Width(contentWidth).Render(strings.Join(lines, "\n")) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } func (m App) helpContentWidth() int { return max(1, min(70, m.width-4)) } func (m App) helpRows(contentWidth int) []string { keyWidth := min(17, max(8, contentWidth/3)) actionWidth := max(1, contentWidth-keyWidth-1) var rows []string for _, binding := range m.helpBindings() { wrapped := ansi.Hardwrap(ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false) actionLines := strings.Split(wrapped, "\n") for index, action := range actionLines { key := "" if index == 0 { key = binding.key } rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action) } } return rows } var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) paneInactiveColor = lipgloss.Color("#50566F") paneActiveColor = lipgloss.Color("#F0B72F") selectedLineBackground = "\x1b[48;5;24m" suggestionRemoveBackground = "\x1b[48;5;52m" suggestionAddBackground = "\x1b[48;5;22m" changedRemoveBackground = "\x1b[48;2;55;0;0m" changedAddBackground = "\x1b[48;2;0;55;0m" ) func paneStyle(active bool) lipgloss.Style { color := paneInactiveColor if active { color = paneActiveColor } return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color) } func (m App) viewPRs() string { header := titleStyle.Render("gh-threads") if m.owner != "" { header += " " + m.owner + "/" + m.repo } if m.showAll { header += dimStyle.Render(" all open pull requests") } else { header += dimStyle.Render(" assigned to you") } lines := []string{header, ""} if m.loading && len(m.prs) == 0 { lines = append(lines, "Loading open pull requests…") } else if len(m.prs) == 0 && m.err == nil { lines = append(lines, "No matching assigned pull requests.") } rows, selectedRow := groupedPRRows(m.prs, m.prIndex) available := max(1, m.height-4) start := windowStart(selectedRow, len(rows), available) for _, row := range rows[start:min(len(rows), start+available)] { if row.header { lines = append(lines, titleStyle.Render(row.repository)) continue } pr := m.prs[row.prIndex] draft := "" if pr.IsDraft { draft = " DRAFT" } if pr.FromCache { draft += " CACHED" } titleWidth := max(10, m.width-36) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) if row.prIndex == m.prIndex { line = activeStyle.Render(line) } lines = append(lines, line) } footer := "? keys • j/k move • enter dashboard • q quit" if m.dashboardMode == "hotkey" { footer = "? keys • j/k move • enter threads • d dashboard • q quit" } return m.frame(lines, footer) } type prListRow struct { repository string prIndex int header bool } func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) { rows := make([]prListRow, 0, len(prs)*2) selectedRow := 0 lastRepository := "" for index, pr := range prs { repository := pr.RepoWithOwner if repository == "" { repository = "(unknown repository)" } if repository != lastRepository { rows = append(rows, prListRow{repository: repository, header: true}) lastRepository = repository } if index == selected { selectedRow = len(rows) } rows = append(rows, prListRow{repository: repository, prIndex: index}) } return rows, selectedRow } func (m App) viewDashboard() string { lines := m.dashboardLines() viewportHeight := m.dashboardViewportHeight() maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] return m.frame(visible, "? keys • j/k scroll • enter threads • b back • q quit") } func (m App) dashboardLines() []string { pr := m.details width := max(10, m.width-2) draft := "" if pr.IsDraft { draft = " " + warnStyle.Render("DRAFT") } lines := []string{ titleStyle.Render(fmt.Sprintf("%s #%d", pr.RepoWithOwner, pr.Number)) + draft, titleStyle.Render(truncate(pr.Title, width)), } if pr.FromCache { lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"))) } if m.loading && pr.BaseRef == "" { return append(lines, "", "Loading pull request details…") } open, outdated, resolved := threadStatusCounts(pr.Threads) created := "unknown" if !pr.CreatedAt.IsZero() { created = pr.CreatedAt.Local().Format("2006-01-02 15:04") } updated := "unknown" if !pr.UpdatedAt.IsZero() { updated = pr.UpdatedAt.Local().Format("2006-01-02 15:04") } milestone := firstNonEmpty(pr.Milestone, "none") labels := "none" if len(pr.Labels) > 0 { labels = strings.Join(pr.Labels, ", ") } lines = append(lines, "", dashboardMetadata("author", authorStyle(pr.Author).Render("@"+pr.Author)), dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef), dashboardMetadata("review", reviewAndMergeState(pr)), dashboardMetadata("checks", coloredState(pr.CheckState)), dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), dashboardMetadata("assignees", handlesText(pr.Assignees)), dashboardMetadata("reviewers", reviewersText(pr.Reviewers)), dashboardMetadata("labels", labels), dashboardMetadata("milestone", milestone), dashboardMetadata("activity", fmt.Sprintf( "%d commits • %d conversation comments", pr.CommitCount, pr.CommentCount, )), dashboardMetadata("changes", fmt.Sprintf( "%s %s • %d files", okStyle.Render(fmt.Sprintf("+%d", pr.Additions)), badStyle.Render(fmt.Sprintf("-%d", pr.Deletions)), pr.ChangedFiles, )), dashboardMetadata("threads", fmt.Sprintf( "%d open • %d outdated • %d resolved", open, outdated, resolved, )), dashboardMetadata("requirements", mergeRequirementsText(pr.Requirements)), dashboardMetadata("permission", viewerPermissionsText(pr.Permissions)), dashboardMetadata("head sha", shortOID(pr.HeadOID)), dashboardMetadata("created", created), dashboardMetadata("updated", updated), dashboardMetadata("url", pr.URL), ) lines = append(lines, "", titleStyle.Render("Write capability gate"), "") lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...) if pr.ThreadsTruncated { lines = append(lines, warnStyle.Render("Thread totals only include the first 100 review threads.")) } lines = append(lines, "", titleStyle.Render("Description"), "") if strings.TrimSpace(pr.Body) == "" { lines = append(lines, dimStyle.Render("No description provided.")) } else { lines = append(lines, renderCommentMarkdown(pr.Body, width)...) } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Checks (%d)", len(pr.Checks))), "") if len(pr.Checks) == 0 { lines = append(lines, dimStyle.Render("No individual checks reported.")) } for _, check := range pr.Checks { line := coloredState(check.State) + " " + check.Name if check.URL != "" { line += " " + dimStyle.Render(check.URL) } lines = append(lines, line) if check.Summary != "" && check.State != "SUCCESS" { lines = append(lines, dimStyle.Render(" "+truncate(strings.Join(strings.Fields(check.Summary), " "), width-2))) } for _, annotation := range check.Annotations { location := fmt.Sprintf("%s:%d", annotation.Path, annotation.StartLine) lines = append(lines, " "+coloredState(strings.ToUpper(annotation.Level))+" "+ location+" "+truncate(firstNonEmpty(annotation.Title, annotation.Message), max(10, width-len(location)-8))) } } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Commit and force-push timeline (%d)", len(pr.Timeline))), "") if len(pr.Timeline) == 0 { lines = append(lines, dimStyle.Render("No commit timeline events reported.")) } for _, event := range pr.Timeline { when := event.CreatedAt.Local().Format("2006-01-02 15:04") if event.Kind == "force-push" { lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+ shortOID(event.AfterOID)+" "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) } else { lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+ " "+authorStyle(event.Author).Render("@"+event.Author)+" "+dimStyle.Render(when)) } } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "") for _, ruleset := range pr.Rulesets { if ruleset.Applies { lines = append(lines, ruleset.Name+" "+dimStyle.Render(strings.ToLower(ruleset.Enforcement))+ " "+strings.Join(ruleset.RuleTypes, ", ")) } } if applicableRulesetCount(pr.Rulesets) == 0 { lines = append(lines, dimStyle.Render("No applicable active rulesets reported.")) } if pr.MergeQueue != nil { lines = append(lines, dashboardMetadata("merge queue", fmt.Sprintf( "%s, position %d", strings.ToLower(pr.MergeQueue.State), pr.MergeQueue.Position, ))) } lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Submitted reviews (%d)", len(pr.Reviews))), "") if len(pr.Reviews) == 0 { lines = append(lines, dimStyle.Render("No submitted reviews.")) } if m.compactReviews { lines = append(lines, compactReviewLines(pr.Reviews, width)...) if len(pr.Reviews) > 0 { lines = append(lines, "") } } else { for _, review := range pr.Reviews { header := authorStyle(review.Author).Render("@"+review.Author) + " " + coloredState(review.State) if !review.SubmittedAt.IsZero() { header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04")) } if review.CommitOID != "" { header += " " + dimStyle.Render(shortOID(review.CommitOID)) } lines = append(lines, header) if strings.TrimSpace(review.Body) != "" { lines = append(lines, renderCommentMarkdown(review.Body, width)...) } lines = append(lines, "") } } lines = append(lines, titleStyle.Render(fmt.Sprintf("Conversation (%d)", len(pr.Conversation))), "") if len(pr.Conversation) == 0 { lines = append(lines, dimStyle.Render("No PR conversation comments.")) } for _, comment := range pr.Conversation { header := authorStyle(comment.Author).Render("@" + comment.Author) if !comment.CreatedAt.IsZero() { header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")) } lines = append(lines, header) lines = append(lines, renderCommentMarkdown(comment.Body, width)...) lines = append(lines, "") } return lines } func compactReviewLines(reviews []ReviewSummary, width int) []string { if len(reviews) == 0 { return nil } stateCounts := make(map[string]int) authorCounts := make(map[string]int) for _, review := range reviews { stateCounts[review.State]++ authorCounts[review.Author]++ } states := make([]string, 0, len(stateCounts)) for state := range stateCounts { states = append(states, state) } sort.Strings(states) authors := make([]string, 0, len(authorCounts)) for author := range authorCounts { authors = append(authors, author) } sort.Slice(authors, func(i, j int) bool { if authorCounts[authors[i]] != authorCounts[authors[j]] { return authorCounts[authors[i]] > authorCounts[authors[j]] } return strings.ToLower(authors[i]) < strings.ToLower(authors[j]) }) summary := make([]string, 0, len(states)+len(authors)) for _, state := range states { summary = append(summary, coloredState(state)+dimStyle.Render(fmt.Sprintf(" ×%d", stateCounts[state]))) } for _, author := range authors { summary = append(summary, authorStyle(author).Render("@"+author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])), ) } lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")} for _, review := range reviews { if body := compactReviewBody(review.Body, width); body != "" { line := authorStyle(review.Author).Render("@"+review.Author) + " " + coloredState(review.State) + dimStyle.Render(" — ") + body lines = append(lines, ansi.Truncate(line, width, "…")) } } return lines } func compactReviewBody(body string, width int) string { if strings.TrimSpace(body) == "" { return "" } rendered := ansi.Strip(strings.Join(renderCommentMarkdown(body, width), " ")) return strings.Join(strings.Fields(rendered), " ") } func dashboardMetadata(label, value string) string { return titleStyle.Render(pad(label+":", 13)) + " " + value } func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) { for _, thread := range threads { switch threadStatus(thread) { case "resolved": resolved++ case "outdated": outdated++ default: open++ } } return open, outdated, resolved } func mergeRequirementsText(requirements MergeRequirements) string { var items []string if requirements.RequiresApprovals { approval := "approvals" if requirements.ApprovalsRequired == 1 { approval = "approval" } items = append(items, fmt.Sprintf("%d %s", requirements.ApprovalsRequired, approval)) } if requirements.RequiresCodeOwnerReview { items = append(items, "code owner review") } if requirements.RequiresStatusChecks { items = append(items, "status checks") } if requirements.RequiresStrictChecks { items = append(items, "up-to-date branch") } if requirements.RequiresDeployments { item := "deployments" if len(requirements.RequiredDeployments) > 0 { item += " (" + strings.Join(requirements.RequiredDeployments, ", ") + ")" } items = append(items, item) } if requirements.RequiresLinearHistory { items = append(items, "linear history") } if requirements.RequiresSignatures { items = append(items, "signed commits") } if requirements.RequiresMergeQueue { items = append(items, "merge queue") } if requirements.RequiresConversation { items = append(items, "resolved conversations") } if len(items) == 0 { return "none reported" } return strings.Join(items, ", ") } func applicableRulesetCount(rulesets []Ruleset) int { count := 0 for _, ruleset := range rulesets { if ruleset.Applies { count++ } } return count } type writeCapability struct { name, reason string authorized bool } func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { if pr.FromCache { reason := "offline cached snapshot" return []writeCapability{ {name: "reply", reason: reason}, {name: "resolve", reason: reason}, {name: "react", reason: reason}, {name: "update branch", reason: reason}, {name: "auto-merge", reason: reason}, } } threadReason := "select a review thread" canReply, canResolve := false, false if thread != nil { canReply = thread.ViewerCanReply canResolve = thread.ViewerCanResolve || thread.ViewerCanUnresolve threadReason = "GitHub did not grant permission for this thread" } return []writeCapability{ capability("reply", canReply, threadReason), capability("resolve / unresolve", canResolve, threadReason), capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission"), capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission"), capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR"), } } func capability(name string, allowed bool, denied string) writeCapability { if allowed { return writeCapability{name: name, authorized: true, reason: "authorized; write UI not implemented"} } return writeCapability{name: name, reason: denied} } func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string { var lines []string for _, item := range writeCapabilities(pr, thread) { marker := badStyle.Render("disabled") if item.authorized { marker = okStyle.Render("ready") } lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason)) } return lines } func (m App) selectedThread() *ReviewThread { if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { return nil } thread := m.details.Threads[m.threadIndex] return &thread } func viewerPermissionsText(permissions ViewerPermissions) string { items := []string{strings.ToLower(firstNonEmpty(permissions.Repository, "unknown"))} if permissions.CanUpdatePR { items = append(items, "update") } if permissions.CanResolveAny { items = append(items, "resolve") } if permissions.CanUnresolveAny { items = append(items, "unresolve") } if permissions.CanReplyAny { items = append(items, "reply") } if permissions.CanEnableMerge { items = append(items, "auto-merge") } return strings.Join(items, ", ") } func (m App) viewThreads() string { pr := m.details header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers) top := []string{header, meta, people} if pr.FromCache { top = append(top, warnStyle.Render( "CACHED snapshot • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")+ " • refreshing live data", )) } if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } contentHeight := max(3, m.height-len(top)-1) var body string if m.width < 70 { if m.focus == threadListPane { body = m.threadList(max(3, m.width), contentHeight) } else { body = m.threadDetail(max(3, m.width), contentHeight) } } else if m.listHidden { body = m.threadDetail(m.width, contentHeight) } else { leftWidth := m.threadListWidth() rightWidth := max(20, m.width-leftWidth-1) left := m.threadList(leftWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } help := "? keys • h/l focus • j/k move/scroll • n/N new • d dashboard • b back • q quit" if m.searching { help = "path words • status:open • author:name • updated:true • enter apply • esc cancel" } return m.frame(append(top, body), help) } func (m App) threadList(width, height int) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) matches := m.matchingThreadIndices() lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))} if m.searching { queryWidth := max(1, innerWidth-len("Filter: ")-1) query := ansi.Truncate(m.searchQuery, queryWidth, "…") lines = append(lines, titleStyle.Render("Filter: ")+query+"█") } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") } selectedPosition := 0 for position, index := range matches { if index == m.threadIndex { selectedPosition = position break } } available := max(1, innerHeight-len(lines)) start := windowStart(selectedPosition, len(matches), available) if len(matches) == 0 && m.searchQuery != "" { lines = append(lines, dimStyle.Render("No matching threads.")) } for _, i := range matches[start:min(len(matches), start+available)] { thread := m.details.Threads[i] icon := "●" if thread.IsResolved { icon = "✓" } else if thread.IsOutdated { icon = "○" } suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) if m.unreadThreads[thread.ID] { suffix += " " + warnStyle.Render("NEW") } pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2) path := truncatePath(thread.Path, pathWidth) if m.pathScroll { path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep) } line := icon + " " + pad(path, pathWidth) + suffix line = ansi.Truncate(line, innerWidth, "") if thread.IsResolved { line = dimStyle.Render(line) } if i == m.threadIndex { line = activeStyle.Render(pad(line, innerWidth)) } lines = append(lines, line) } return renderPane(lines, width, height, m.focus == threadListPane) } func (m App) threadDetail(width, height int) string { if len(m.details.Threads) == 0 { return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane) } lines := m.detailLines(width) viewportHeight := max(1, height-2) maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] rendered := make([]string, 0, len(visible)) innerWidth := max(1, width-2) for _, line := range visible { railWidth := ansi.StringWidth(line.rail) contentWidth := max(1, innerWidth-railWidth) var renderedLine string if line.suggestionChange != 0 { renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange) } else { renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "…") } renderedLine = line.rail + renderedLine if line.selected { renderedLine = selectedBackground(renderedLine, innerWidth) } rendered = append(rendered, renderedLine) } return renderPane(rendered, width, height, m.focus == threadDetailPane) } type detailLine struct { text string fixed string rail string selected bool suggestionChange byte anchor string } func (m App) detailLines(width int) []detailLine { if len(m.details.Threads) == 0 { return nil } thread := m.details.Threads[m.threadIndex] status := "open" if thread.IsResolved { status = "resolved" } if thread.IsOutdated { status += ", outdated" } if m.unreadThreads[thread.ID] { status += ", new updates" } if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" { status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID) } if pushedAt := latestForcePush(m.details.Timeline); !pushedAt.IsZero() && threadOpenedAt(thread).Before(pushedAt) { status += ", predates latest force-push" } lines := []detailLine{ {anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, } if m.folded[thread.ID] { lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")}) } else { if len(thread.Comments) > 0 { lines = append(lines, detailLine{}) startLine, endLine := reviewAnchor(thread) for codeIndex, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) { wrapped := wrapDiffLine(codeLine, max(1, width-2)) for wrapIndex := range wrapped { wrapped[wrapIndex].anchor = fmt.Sprintf("code:%d:%d", codeIndex, wrapIndex) } lines = append(lines, wrapped...) } } for _, comment := range thread.Comments { content := parseCommentBody(comment.Body) rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ") lines = append(lines, detailLine{}, detailLine{ rail: rail, anchor: "comment:" + comment.ID + ":header", text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), }) if content.Prose != "" { for lineIndex, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) { lines = append(lines, detailLine{ rail: rail, text: commentLine, anchor: fmt.Sprintf("comment:%s:body:%d", comment.ID, lineIndex), }) } } for suggestionIndex, suggestion := range content.Suggestions { removed, added := normalizeSuggestion(reviewedSourceLines(thread, comment), suggestion) removedRanges, addedRanges := suggestionChangedRanges(removed, added) label := "suggested change" if len(content.Suggestions) > 1 { label = fmt.Sprintf("suggested change %d/%d", suggestionIndex+1, len(content.Suggestions)) } lines = append(lines, detailLine{rail: rail}, detailLine{rail: rail, text: dimStyle.Render(label)}) for sourceIndex, source := range removed { lines = append(lines, addCommentRail( wrapSuggestionLine( thread.Path, source, '-', removedRanges[sourceIndex], max(1, width-4), ), rail, )...) } for sourceIndex, source := range added { lines = append(lines, addCommentRail( wrapSuggestionLine( thread.Path, source, '+', addedRanges[sourceIndex], max(1, width-4), ), rail, )...) } } } if thread.IsTruncated { lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")}) } } return lines } func latestForcePush(events []TimelineEvent) time.Time { var latest time.Time for _, event := range events { if event.Kind == "force-push" && event.CreatedAt.After(latest) { latest = event.CreatedAt } } return latest } func (m App) detailScrollAnchor() string { width, _ := m.detailPaneSize() lines := m.detailLines(width) for index := min(m.scroll, len(lines)-1); index >= 0; index-- { if lines[index].anchor != "" { return lines[index].anchor } } return "" } func (m *App) restoreDetailAnchor(anchor string) { width, _ := m.detailPaneSize() for index, line := range m.detailLines(width) { if line.anchor == anchor { m.scroll = min(index, m.detailMaxScroll()) return } } m.scroll = min(m.scroll, m.detailMaxScroll()) } func wrapDiffLine(line highlightedDiffLine, width int) []detailLine { gutterWidth := ansi.StringWidth(line.gutter) if gutterWidth == 0 { wrapped := ansi.Hardwrap(line.code, width, true) parts := strings.Split(wrapped, "\n") result := make([]detailLine, 0, len(parts)) for _, part := range parts { result = append(result, detailLine{text: part, selected: line.selected}) } return result } codeWidth := max(1, width-gutterWidth) parts := wrapCodeWithIndent(line.code, codeWidth) result := make([]detailLine, 0, len(parts)) for i, part := range parts { gutter := line.gutter if i > 0 { gutter = strings.Repeat(" ", gutterWidth) } result = append(result, detailLine{ text: part, fixed: gutter, selected: line.selected, }) } return result } func wrapSuggestionLine(path, source string, change byte, changed codeRange, width int) []detailLine { marker := badStyle.Render(string(change)) if change == '+' { marker = okStyle.Render(string(change)) } code := highlightedSource(lexerForPath(path), source) code = highlightCodeRange(code, changed, change) lines := wrapDiffLine(highlightedDiffLine{ gutter: marker + " ", code: code, }, width) for i := range lines { lines[i].suggestionChange = change } return lines } func highlightCodeRange(code string, changed codeRange, change byte) string { if changed.End <= changed.Start { return code } totalWidth := ansi.StringWidth(code) start := clamp(changed.Start, 0, totalWidth) end := clamp(changed.End, start, totalWidth) if end <= start { return code } background := changedRemoveBackground if change == '+' { background = changedAddBackground } if background == "" { return code } const reset = "\x1b[0m" before := ansi.Cut(code, 0, start) middle := ansi.Cut(code, start, end) after := ansi.Cut(code, end, totalWidth) middle = strings.ReplaceAll(middle, reset, reset+background) return before + background + middle + reset + after } func addCommentRail(lines []detailLine, rail string) []detailLine { for i := range lines { lines[i].rail = rail } return lines } func wrapCodeWithIndent(code string, width int) []string { if width <= 0 || ansi.StringWidth(code) <= width { return []string{code} } plain := ansi.Strip(code) indent := len(plain) - len(strings.TrimLeft(plain, " ")) totalWidth := ansi.StringWidth(code) continuationPrefix := strings.Repeat(" ", indent+2) + dimStyle.Render("↳ ") continuationWidth := max(1, width-ansi.StringWidth(continuationPrefix)) parts := make([]string, 0, totalWidth/width+1) offset := 0 for offset < totalWidth { available := width prefix := "" if offset > 0 { available = continuationWidth prefix = continuationPrefix } end := syntaxBreakColumn(plain, offset, available) if end <= offset { end = min(totalWidth, offset+available) } parts = append(parts, prefix+ansi.Cut(code, offset, end)) offset = end } return parts } func syntaxBreakColumn(code string, start, width int) int { limit := start + width column := 0 best := -1 for _, r := range code { column += ansi.StringWidth(string(r)) if column <= start { continue } if column > limit { break } if isCodeBreakpoint(r) { best = column } } if best > start { return best } return min(ansi.StringWidth(code), limit) } func isCodeBreakpoint(r rune) bool { if r == ' ' || r == '\t' { return true } return strings.ContainsRune(",.;:()[]{}+-*/%=<>|&", r) } func reviewAnchor(thread ReviewThread) (int, int) { if len(thread.Comments) > 0 { return commentReviewAnchor(thread, thread.Comments[0]) } return commentReviewAnchor(thread, ReviewComment{}) } func shortOID(oid string) string { if len(oid) <= 7 { return oid } return oid[:7] } func selectedBackground(line string, width int) string { const reset = "\x1b[0m" background := selectedLineBackground line = pad(ansi.Truncate(line, width, ""), width) if background == "" { return line } line = strings.ReplaceAll(line, reset, reset+background) return background + line + reset } func suggestionHighlight(gutter, code string, width int, change byte) string { background := suggestionRemoveBackground if change == '+' { background = suggestionAddBackground } if background == "" { return pad(ansi.Truncate(gutter+code, width, ""), width) } const reset = "\x1b[0m" gutter = ansi.Truncate(gutter, width, "") gutter = strings.ReplaceAll(gutter, reset, reset+background) codeWidth := max(0, width-ansi.StringWidth(gutter)) code = ansi.Truncate(code, codeWidth, "") code = strings.ReplaceAll(code, reset, reset+background) content := background + gutter + code + reset padding := max(0, width-ansi.StringWidth(gutter)-ansi.StringWidth(code)) if padding > 0 { content += background + strings.Repeat(" ", padding) + reset } return content } var authorPalette = []lipgloss.Color{ "#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#E06C75", "#98C379", "#D19A66", "#7FC8FF", } func authorStyle(login string) lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login)) } func authorColor(login string) lipgloss.Color { hash := fnv.New32a() _, _ = hash.Write([]byte(strings.ToLower(login))) return authorPalette[int(hash.Sum32())%len(authorPalette)] } func (m App) frame(lines []string, help string) string { body := strings.Join(lines, "\n") status := "" if m.err != nil { status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8))) } if m.loading { status = warnStyle.Render("refreshing…") } if status == "" && !m.lastRefresh.IsZero() { status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05")) } footer := truncate(help, m.width) if status != "" { footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status } bodyLines := strings.Split(body, "\n") if len(bodyLines) > max(0, m.height-1) { bodyLines = bodyLines[:max(0, m.height-1)] } for i := range bodyLines { bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "") } bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, "")) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) } func renderPane(lines []string, width, height int, active bool) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) if len(lines) > innerHeight { lines = lines[:innerHeight] } for i := range lines { lines[i] = ansi.Truncate(lines[i], innerWidth, "") } return paneStyle(active).Width(innerWidth).Height(innerHeight).Render(strings.Join(lines, "\n")) } func reviewAndMergeState(pr PRDetails) string { switch pr.ReviewDecision { case "APPROVED": review := okStyle.Render("review: approved") open, outdated, _ := threadStatusCounts(pr.Threads) if pr.Requirements.RequiresConversation && open+outdated > 0 { return review + " " + warnStyle.Render("merge: unresolved conversations") } if pr.Requirements.RequiresStatusChecks && (pr.CheckState == "FAILURE" || pr.CheckState == "ERROR") { return review + " " + badStyle.Render("merge: checks failing") } switch pr.Mergeable { case "MERGEABLE": return review + " " + okStyle.Render("merge: ready") case "CONFLICTING": return review + " " + badStyle.Render("merge: conflicts") default: return review + " " + warnStyle.Render("merge: checking") } case "CHANGES_REQUESTED": return badStyle.Render("review: changes requested") case "REVIEW_REQUIRED": return warnStyle.Render("review: required") default: return warnStyle.Render("review: pending") } } func coloredState(state string) string { switch state { case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED": return okStyle.Render(state) case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE": return badStyle.Render(state) default: return warnStyle.Render(state) } } func reviewersText(reviewers []Reviewer) string { if len(reviewers) == 0 { return "none" } items := make([]string, 0, len(reviewers)) for _, reviewer := range reviewers { items = append(items, authorStyle(reviewer.Login).Render("@"+reviewer.Login)+ dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"), ) } return strings.Join(items, ", ") } func handlesText(items []string) string { if len(items) == 0 { return "none" } handles := make([]string, 0, len(items)) for _, item := range items { handles = append(handles, authorStyle(item).Render("@"+item)) } return strings.Join(handles, ", ") } func indexPR(items []PullRequest, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func indexThread(items []ReviewThread, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func clamp(value, low, high int) int { if high < low { return low } return min(max(value, low), high) } func windowStart(index, count, size int) int { if count <= size { return 0 } return clamp(index-size/2, 0, count-size) } func truncate(s string, width int) string { if width <= 0 { return "" } return ansi.Truncate(strings.ReplaceAll(s, "\n", " "), width, "…") } func truncatePath(path string, width int) string { if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth) } func scrollingPath(path string, width, step int) string { const pauseSteps = 4 if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } visibleWidth := width - 1 maxOffset := pathWidth - visibleWidth cycle := pauseSteps + maxOffset + pauseSteps phase := step % cycle offset := 0 switch { case phase < pauseSteps: offset = 0 case phase < pauseSteps+maxOffset: offset = phase - pauseSteps default: offset = maxOffset } if offset == 0 { return ansi.Cut(path, 0, visibleWidth) + "…" } if offset == maxOffset { return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth) } return "…" + ansi.Cut(path, offset, offset+width-2) + "…" } func fuzzyPathScore(path, query string) (int, bool) { candidate := []rune(strings.ToLower(path)) terms := strings.Fields(strings.ToLower(query)) if len(terms) == 0 { return 0, true } total := 0 for _, term := range terms { score, ok := fuzzyTermScore(candidate, []rune(term)) if !ok { return 0, false } total += score } return total, true } func fuzzyTermScore(candidate, needle []rune) (int, bool) { if start := strings.Index(string(candidate), string(needle)); start >= 0 { return 10000 - start*10 - len(candidate), true } score, candidateIndex, previous := 0, 0, -2 for _, wanted := range needle { found := -1 for candidateIndex < len(candidate) { if candidate[candidateIndex] == wanted { found = candidateIndex candidateIndex++ break } candidateIndex++ } if found < 0 { return 0, false } score += 20 if found == previous+1 { score += 15 } if found == 0 || strings.ContainsRune("/._-", candidate[found-1]) { score += 12 } score -= found previous = found } return score - len(candidate), true } func pad(s string, width int) string { n := width - lipgloss.Width(s) if n <= 0 { return truncate(s, width) } return s + strings.Repeat(" ", n) } func wrap(text string, width int) string { words := strings.Fields(text) if len(words) == 0 { return "" } lines, line := []string{}, words[0] for _, word := range words[1:] { if len([]rune(line))+1+len([]rune(word)) > width { lines, line = append(lines, line), word } else { line += " " + word } } return strings.Join(append(lines, line), "\n") }