Files
diple/tui.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")
}