Files
diple/tui.go
2026-07-28 09:52:23 +02:00

1319 lines
35 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"
"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 pathTickMsg 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
foldResolved bool
threadListWidthPercent int
pathScroll bool
pathScrollInterval time.Duration
pathScrollStep int
}
type AppSettings struct {
FoldResolved bool
ThreadListWidthPercent int
PathScroll bool
PathScrollInterval time.Duration
}
func defaultAppSettings() AppSettings {
return AppSettings{
FoldResolved: true,
ThreadListWidthPercent: 33,
PathScrollInterval: 350 * time.Millisecond,
}
}
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 {
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,
}
}
func (m App) Init() tea.Cmd {
commands := []tea.Cmd{m.loadPRs(), 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() 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 pathTickMsg:
if m.pathScroll && m.screen == threadScreen {
m.pathScrollStep++
}
if m.pathScroll {
return m, m.nextPathTick()
}
return m, nil
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.foldResolved {
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, ""
m.pathScrollStep = 0
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, ""
m.pathScrollStep = 0
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 := 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()
}
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"))
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"
}
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 := 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 • 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)
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
}
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 := changedRemoveBackground
if change == '+' {
background = changedAddBackground
}
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)
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
}
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 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")
}