883 lines
24 KiB
Go
883 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"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 {
|
|
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
|
|
}
|
|
|
|
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(number int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
details, err := m.service.GetPullRequest(ctx, m.owner, m.repo, number)
|
|
return detailsLoadedMsg{number: 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.Number), 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 := 0
|
|
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
|
|
selected = m.prs[m.prIndex].Number
|
|
}
|
|
m.prs = msg.prs
|
|
m.prIndex = indexPR(m.prs, selected)
|
|
m.err = nil
|
|
m.lastRefresh = time.Now()
|
|
case detailsLoadedMsg:
|
|
m.loading = false
|
|
if msg.number != m.details.Number && m.details.Number != 0 {
|
|
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 k == "ctrl+c" || k == "q" {
|
|
return m, tea.Quit
|
|
}
|
|
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 "r":
|
|
if m.loading {
|
|
return m, nil
|
|
}
|
|
m.loading = true
|
|
if m.screen == threadScreen {
|
|
return m, m.loadDetails(m.details.Number)
|
|
}
|
|
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
|
|
return m, m.loadDetails(m.details.Number)
|
|
}
|
|
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
|
|
return m, m.loadDetails(m.details.Number)
|
|
}
|
|
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
|
|
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) 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.screen == prScreen {
|
|
return m.viewPRs()
|
|
}
|
|
return m.viewThreads()
|
|
}
|
|
|
|
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") + " " + m.owner + "/" + m.repo
|
|
if !m.showAll {
|
|
header += dimStyle.Render(" authored by 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 open pull requests.")
|
|
}
|
|
available := max(1, m.height-6)
|
|
start := windowStart(m.prIndex, len(m.prs), available)
|
|
for i := start; i < min(len(m.prs), start+available); i++ {
|
|
pr := m.prs[i]
|
|
draft := ""
|
|
if pr.IsDraft {
|
|
draft = " DRAFT"
|
|
}
|
|
line := fmt.Sprintf("#%-5d %-*s %2d threads%s", pr.Number, max(10, m.width-34), truncate(pr.Title, max(10, m.width-34)), pr.ReviewCount, draft)
|
|
if i == m.prIndex {
|
|
line = activeStyle.Render(line)
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return m.frame(lines, "j/k move • enter/l open • g/G top/bottom • r refresh • q quit")
|
|
}
|
|
|
|
func (m App) viewThreads() string {
|
|
pr := m.details
|
|
header := titleStyle.Render(fmt.Sprintf("#%d %s", pr.Number, truncate(pr.Title, max(10, m.width-10))))
|
|
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
|
|
people := "assignees: " + joinOrNone(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)
|
|
}
|
|
return m.frame(append(top, body), "tab list • h/l focus • j/k move/scroll • ctrl-d/u page • za fold • b back • q quit")
|
|
}
|
|
|
|
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.loading && len(m.details.Threads) == 0 {
|
|
lines = append(lines, "Loading…")
|
|
}
|
|
available := max(1, innerHeight-1)
|
|
start := windowStart(m.threadIndex, len(m.details.Threads), available)
|
|
for i := start; i < min(len(m.details.Threads), start+available); i++ {
|
|
thread := m.details.Threads[i]
|
|
icon := "●"
|
|
if thread.IsResolved {
|
|
icon = "✓"
|
|
}
|
|
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 strings.Split(wrap(content.Prose, max(10, width-4)), "\n") {
|
|
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, reviewer.Login+"("+strings.ToLower(reviewer.State)+")")
|
|
}
|
|
return strings.Join(items, ", ")
|
|
}
|
|
func joinOrNone(items []string) string {
|
|
if len(items) == 0 {
|
|
return "none"
|
|
}
|
|
return strings.Join(items, ", ")
|
|
}
|
|
func indexPR(items []PullRequest, number int) int {
|
|
for i, item := range items {
|
|
if item.Number == number {
|
|
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 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")
|
|
}
|