Files
diple/tui.go

2261 lines
64 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}