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") }