1213 lines
32 KiB
Go
1213 lines
32 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/charmbracelet/x/ansi"
|
||
)
|
||
|
||
type screen int
|
||
|
||
const (
|
||
prScreen screen = iota
|
||
threadScreen
|
||
)
|
||
|
||
type pane int
|
||
|
||
const (
|
||
threadListPane pane = iota
|
||
threadDetailPane
|
||
)
|
||
|
||
type tickMsg time.Time
|
||
type prsLoadedMsg struct {
|
||
prs []PullRequest
|
||
err error
|
||
}
|
||
type detailsLoadedMsg struct {
|
||
owner string
|
||
repo string
|
||
number int
|
||
details PRDetails
|
||
err error
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
|
||
return App{
|
||
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
|
||
folded: make(map[string]bool), loading: true,
|
||
}
|
||
}
|
||
|
||
func (m App) Init() tea.Cmd {
|
||
return tea.Batch(m.loadPRs(), m.nextTick())
|
||
}
|
||
|
||
func (m App) nextTick() tea.Cmd {
|
||
return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||
}
|
||
|
||
func (m App) loadPRs() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
defer cancel()
|
||
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) tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
defer cancel()
|
||
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 == threadScreen && m.details.Number != 0 {
|
||
return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick())
|
||
}
|
||
return m, tea.Batch(m.loadPRs(), m.nextTick())
|
||
}
|
||
return m, m.nextTick()
|
||
case prsLoadedMsg:
|
||
m.loading = false
|
||
if msg.err != 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
|
||
m.lastRefresh = time.Now()
|
||
case detailsLoadedMsg:
|
||
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 {
|
||
m.err = msg.err
|
||
return m, nil
|
||
}
|
||
selected := ""
|
||
if m.threadIndex < len(m.details.Threads) {
|
||
selected = m.details.Threads[m.threadIndex].ID
|
||
}
|
||
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.folded[thread.ID] = true
|
||
}
|
||
}
|
||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||
m.err = nil
|
||
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, m.searchQuery = 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 "/":
|
||
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 == threadScreen {
|
||
return m, m.loadDetails(m.details.PullRequest)
|
||
}
|
||
return m, m.loadPRs()
|
||
case "j", "down":
|
||
if m.screen == threadScreen && m.focus == threadDetailPane {
|
||
m.scrollDetail(1)
|
||
} else {
|
||
m.move(1)
|
||
}
|
||
case "k", "up":
|
||
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 "l":
|
||
if m.screen == prScreen && len(m.prs) > 0 {
|
||
m.screen = threadScreen
|
||
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, ""
|
||
return m, m.loadDetails(m.details.PullRequest)
|
||
}
|
||
if m.screen == threadScreen {
|
||
m.focus = threadDetailPane
|
||
}
|
||
case "h":
|
||
if m.screen == threadScreen {
|
||
m.focus = threadListPane
|
||
m.listHidden = false
|
||
}
|
||
case "enter":
|
||
if m.screen == prScreen && len(m.prs) > 0 {
|
||
m.screen = threadScreen
|
||
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, ""
|
||
return m, m.loadDetails(m.details.PullRequest)
|
||
}
|
||
if 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
|
||
}
|
||
case "b", "esc":
|
||
if m.screen == threadScreen {
|
||
m.screen, m.err, m.loading = prScreen, nil, true
|
||
m.searching, m.searchQuery = false, ""
|
||
return m, m.loadPRs()
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *App) move(delta int) {
|
||
if m.screen == prScreen {
|
||
m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1)
|
||
return
|
||
}
|
||
m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1)
|
||
m.scroll = 0
|
||
}
|
||
|
||
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
|
||
}
|
||
matches := make([]match, 0, len(m.details.Threads))
|
||
for i, thread := range m.details.Threads {
|
||
if score, ok := fuzzyPathScore(thread.Path, m.searchQuery); 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
|
||
}
|
||
|
||
func (m *App) toStart() {
|
||
if m.screen == prScreen {
|
||
m.prIndex = 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.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 == 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) 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 := clamp(m.width/3, 30, 48)
|
||
return max(20, m.width-leftWidth-1), height
|
||
}
|
||
|
||
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()
|
||
}
|
||
return m.viewThreads()
|
||
}
|
||
|
||
type helpBinding struct {
|
||
key string
|
||
action string
|
||
}
|
||
|
||
func (m App) helpBindings() []helpBinding {
|
||
if m.screen == prScreen {
|
||
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", "Open pull request"},
|
||
{"r", "Refresh now"},
|
||
{"?", "Close this help"},
|
||
{"q / ctrl-c", "Quit"},
|
||
}
|
||
}
|
||
return []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"},
|
||
{"enter / za", "Fold / expand thread"},
|
||
{"b / esc", "Return to pull requests"},
|
||
{"r", "Refresh now"},
|
||
{"?", "Close this help"},
|
||
{"q / ctrl-c", "Quit"},
|
||
}
|
||
}
|
||
|
||
func (m App) helpVisibleRows() int {
|
||
return max(1, m.height-5)
|
||
}
|
||
|
||
func (m App) helpMaxScroll() int {
|
||
return max(0, len(m.helpBindings())-m.helpVisibleRows())
|
||
}
|
||
|
||
func (m App) viewHelp() string {
|
||
bindings := m.helpBindings()
|
||
visibleRows := m.helpVisibleRows()
|
||
start := clamp(m.helpScroll, 0, max(0, len(bindings)-visibleRows))
|
||
end := min(len(bindings), start+visibleRows)
|
||
contentWidth := max(1, min(70, m.width-4))
|
||
keyWidth := min(17, max(8, contentWidth/3))
|
||
|
||
title := "Pull request picker keys"
|
||
if m.screen == threadScreen {
|
||
title = "Review thread keys"
|
||
}
|
||
lines := []string{titleStyle.Render(title)}
|
||
for _, binding := range bindings[start:end] {
|
||
key := pad(binding.key, keyWidth)
|
||
actionWidth := max(1, contentWidth-keyWidth-1)
|
||
lines = append(lines, titleStyle.Render(key)+" "+truncate(binding.action, actionWidth))
|
||
}
|
||
if len(bindings) > visibleRows {
|
||
lines = append(lines, dimStyle.Render(fmt.Sprintf(
|
||
"%d–%d of %d • j/k scroll • ?/esc/q close",
|
||
start+1, end, len(bindings),
|
||
)))
|
||
} 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)
|
||
}
|
||
|
||
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"))
|
||
)
|
||
|
||
func paneStyle(active bool) lipgloss.Style {
|
||
color := lipgloss.Color("#50566F")
|
||
if active {
|
||
color = lipgloss.Color("#F0B72F")
|
||
}
|
||
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"
|
||
}
|
||
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)
|
||
}
|
||
return m.frame(lines, "? keys • j/k move • enter open • q quit")
|
||
}
|
||
|
||
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) 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.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 := clamp(m.width/3, 30, 48)
|
||
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 • b back • q quit"
|
||
if m.searching {
|
||
help = "type to fuzzy search • ↑/↓ choose • enter jump • 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)
|
||
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d)", len(m.details.Threads)))}
|
||
if m.searching {
|
||
queryWidth := max(1, innerWidth-len("Find file: ")-1)
|
||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
||
lines = append(lines, titleStyle.Render("Find file: ")+query+"█")
|
||
}
|
||
if m.loading && len(m.details.Threads) == 0 {
|
||
lines = append(lines, "Loading…")
|
||
}
|
||
matches := m.matchingThreadIndices()
|
||
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 m.searching && len(matches) == 0 {
|
||
lines = append(lines, dimStyle.Render("No matching files."))
|
||
}
|
||
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))
|
||
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
|
||
line := icon + " " + pad(truncatePath(thread.Path, pathWidth), 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
|
||
}
|
||
|
||
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 len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" {
|
||
status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID)
|
||
}
|
||
lines := []detailLine{
|
||
{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 _, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) {
|
||
lines = append(lines, wrapDiffLine(codeLine, max(1, width-2))...)
|
||
}
|
||
}
|
||
for _, comment := range thread.Comments {
|
||
content := parseCommentBody(comment.Body)
|
||
rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ")
|
||
lines = append(lines, detailLine{}, detailLine{
|
||
rail: rail,
|
||
text: authorStyle(comment.Author).Render("@"+comment.Author) + " " +
|
||
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
|
||
})
|
||
if content.Prose != "" {
|
||
for _, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) {
|
||
lines = append(lines, detailLine{rail: rail, text: commentLine})
|
||
}
|
||
}
|
||
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 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 := "\x1b[48;2;55;0;0m"
|
||
if change == '+' {
|
||
background = "\x1b[48;2;0;55;0m"
|
||
}
|
||
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 (
|
||
background = "\x1b[48;5;24m"
|
||
reset = "\x1b[0m"
|
||
)
|
||
line = pad(ansi.Truncate(line, width, ""), width)
|
||
line = strings.ReplaceAll(line, reset, reset+background)
|
||
return background + line + reset
|
||
}
|
||
|
||
func suggestionHighlight(gutter, code string, width int, change byte) string {
|
||
background := "\x1b[48;5;52m"
|
||
if change == '+' {
|
||
background = "\x1b[48;5;22m"
|
||
}
|
||
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")
|
||
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":
|
||
return okStyle.Render(state)
|
||
case "FAILURE", "ERROR":
|
||
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 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")
|
||
}
|