initial working read only state
This commit is contained in:
791
tui.go
Normal file
791
tui.go
Normal file
@@ -0,0 +1,791 @@
|
||||
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 {
|
||||
renderedLine := ansi.Truncate(line.fixed+line.text, innerWidth, "…")
|
||||
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
|
||||
selected bool
|
||||
}
|
||||
|
||||
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 {
|
||||
lines = append(lines, detailLine{}, detailLine{text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))})
|
||||
for _, commentLine := range strings.Split(wrap(comment.Body, max(10, width-4)), "\n") {
|
||||
lines = append(lines, detailLine{text: commentLine})
|
||||
}
|
||||
}
|
||||
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 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 {
|
||||
comment := thread.Comments[0]
|
||||
end := comment.OriginalLine
|
||||
start := comment.OriginalStartLine
|
||||
if end == 0 {
|
||||
end = comment.Line
|
||||
start = comment.StartLine
|
||||
}
|
||||
if end > 0 {
|
||||
if start == 0 {
|
||||
start = end
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
}
|
||||
start := thread.StartLine
|
||||
if start == 0 {
|
||||
start = thread.Line
|
||||
}
|
||||
return start, thread.Line
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user