package main import ( "context" "fmt" "hash/fnv" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) type screen int const ( prScreen screen = iota threadScreen ) type pane int const ( threadListPane pane = iota threadDetailPane ) type tickMsg time.Time type pathTickMsg time.Time type prsLoadedMsg struct { prs []PullRequest err error } type detailsLoadedMsg struct { owner string repo string number int details PRDetails err error } type App struct { service GitHubService owner, repo string showAll bool limit int poll time.Duration screen screen prs []PullRequest prIndex int details PRDetails threadIndex int folded map[string]bool focus pane listHidden bool scroll int width, height int loading bool err error lastRefresh time.Time pendingZ bool searching bool searchQuery string searchOrigin int helpVisible bool helpScroll int foldResolved bool threadListWidthPercent int pathScroll bool pathScrollInterval time.Duration pathScrollStep int } type AppSettings struct { FoldResolved bool ThreadListWidthPercent int PathScroll bool PathScrollInterval time.Duration } func defaultAppSettings() AppSettings { return AppSettings{ FoldResolved: true, ThreadListWidthPercent: 33, PathScrollInterval: 350 * time.Millisecond, } } func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App { return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings()) } func NewAppWithSettings( service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration, settings AppSettings, ) App { return App{ service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll, folded: make(map[string]bool), loading: true, foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent, pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval, } } func (m App) Init() tea.Cmd { commands := []tea.Cmd{m.loadPRs(), m.nextTick()} if m.pathScroll { commands = append(commands, m.nextPathTick()) } return tea.Batch(commands...) } func (m App) nextTick() tea.Cmd { return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m App) nextPathTick() tea.Cmd { return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) }) } func (m App) loadPRs() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() prs, err := m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll) return prsLoadedMsg{prs: prs, err: err} } } func (m App) loadDetails(pr PullRequest) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() details, err := m.service.GetPullRequest(ctx, pr.Owner, pr.Repository, pr.Number) return detailsLoadedMsg{ owner: pr.Owner, repo: pr.Repository, number: pr.Number, details: details, err: err, } } } func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tickMsg: if !m.loading { m.loading = true if m.screen == threadScreen && m.details.Number != 0 { return m, tea.Batch(m.loadDetails(m.details.PullRequest), m.nextTick()) } return m, tea.Batch(m.loadPRs(), m.nextTick()) } return m, m.nextTick() case pathTickMsg: if m.pathScroll && m.screen == threadScreen { m.pathScrollStep++ } if m.pathScroll { return m, m.nextPathTick() } return m, nil case prsLoadedMsg: m.loading = false if msg.err != nil { m.err = msg.err return m, nil } selected := "" if len(m.prs) > 0 && m.prIndex < len(m.prs) { selected = m.prs[m.prIndex].ID } m.prs = msg.prs sort.SliceStable(m.prs, func(i, j int) bool { left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner) if left != right { return left < right } return m.prs[i].UpdatedAt.After(m.prs[j].UpdatedAt) }) m.prIndex = indexPR(m.prs, selected) m.err = nil m.lastRefresh = time.Now() case detailsLoadedMsg: m.loading = false if m.details.Number != 0 && (msg.number != m.details.Number || msg.owner != m.details.Owner || msg.repo != m.details.Repository) { return m, nil } if msg.err != nil { m.err = msg.err return m, nil } selected := "" if m.threadIndex < len(m.details.Threads) { selected = m.details.Threads[m.threadIndex].ID } m.details = msg.details m.threadIndex = indexThread(m.details.Threads, selected) if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) { m.scroll = 0 } for _, thread := range m.details.Threads { if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved { m.folded[thread.ID] = true } } m.scroll = min(m.scroll, m.detailMaxScroll()) m.err = nil m.lastRefresh = time.Now() } key, ok := msg.(tea.KeyMsg) if !ok { return m, nil } k := key.String() if m.helpVisible { switch k { case "ctrl+c": return m, tea.Quit case "?", "esc", "q": m.helpVisible, m.helpScroll = false, 0 case "j", "down": m.helpScroll = min(m.helpScroll+1, m.helpMaxScroll()) case "k", "up": m.helpScroll = max(0, m.helpScroll-1) case "g": m.helpScroll = 0 case "G": m.helpScroll = m.helpMaxScroll() case "ctrl+d", "pgdown": m.helpScroll = min(m.helpScroll+max(3, m.height/2), m.helpMaxScroll()) case "ctrl+u", "pgup": m.helpScroll = max(0, m.helpScroll-max(3, m.height/2)) } return m, nil } if m.searching { switch k { case "ctrl+c": return m, tea.Quit case "esc": m.searching, m.searchQuery = false, "" m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 case "enter": m.searching, m.searchQuery = false, "" case "up": m.moveSearch(-1) case "down", "tab": m.moveSearch(1) case "backspace": runes := []rune(m.searchQuery) if len(runes) > 0 { m.searchQuery = string(runes[:len(runes)-1]) m.selectBestSearchMatch() } case "ctrl+u": m.searchQuery = "" m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.scroll = 0 default: if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { m.searchQuery += string(key.Runes) m.selectBestSearchMatch() } } return m, nil } if k == "ctrl+c" || k == "q" { return m, tea.Quit } if k == "?" { m.helpVisible, m.helpScroll = true, 0 return m, nil } if m.pendingZ { m.pendingZ = false if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 } return m, nil } if k == "z" && m.screen == threadScreen { m.pendingZ = true return m, nil } switch k { case "/": if m.screen == threadScreen { m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex m.focus, m.listHidden, m.scroll = threadListPane, false, 0 } case "r": if m.loading { return m, nil } m.loading = true if m.screen == threadScreen { return m, m.loadDetails(m.details.PullRequest) } return m, m.loadPRs() case "j", "down": if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(1) } else { m.move(1) } case "k", "up": if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(-1) } else { m.move(-1) } case "g": m.toStart() case "G": m.toEnd() case "tab": if m.screen == threadScreen { m.listHidden = !m.listHidden if m.listHidden { m.focus = threadDetailPane } else { m.focus = threadListPane } m.scroll = 0 } case "ctrl+d", "pgdown": m.page(1) case "ctrl+u", "pgup": m.page(-1) case "l": if m.screen == prScreen && len(m.prs) > 0 { m.screen = threadScreen m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 return m, m.loadDetails(m.details.PullRequest) } if m.screen == threadScreen { m.focus = threadDetailPane } case "h": if m.screen == threadScreen { m.focus = threadListPane m.listHidden = false } case "enter": if m.screen == prScreen && len(m.prs) > 0 { m.screen = threadScreen m.details = PRDetails{PullRequest: m.prs[m.prIndex]} m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil m.searching, m.searchQuery = false, "" m.pathScrollStep = 0 return m, m.loadDetails(m.details.PullRequest) } if m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 } case "b", "esc": if m.screen == threadScreen { m.screen, m.err, m.loading = prScreen, nil, true m.searching, m.searchQuery = false, "" return m, m.loadPRs() } } return m, nil } func (m *App) move(delta int) { if m.screen == prScreen { m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1) return } m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1) m.scroll = 0 } func (m *App) moveSearch(delta int) { matches := m.matchingThreadIndices() if len(matches) == 0 { return } position := 0 for i, index := range matches { if index == m.threadIndex { position = i break } } position = clamp(position+delta, 0, len(matches)-1) m.threadIndex = matches[position] m.scroll = 0 } func (m *App) selectBestSearchMatch() { matches := m.matchingThreadIndices() if len(matches) > 0 { m.threadIndex = matches[0] m.scroll = 0 } } func (m App) matchingThreadIndices() []int { if m.searchQuery == "" { indices := make([]int, len(m.details.Threads)) for i := range m.details.Threads { indices[i] = i } return indices } type match struct { index int score int } matches := make([]match, 0, len(m.details.Threads)) for i, thread := range m.details.Threads { if score, ok := fuzzyPathScore(thread.Path, m.searchQuery); ok { matches = append(matches, match{index: i, score: score}) } } sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) indices := make([]int, len(matches)) for i, match := range matches { indices[i] = match.index } return indices } func (m *App) toStart() { if m.screen == prScreen { m.prIndex = 0 } else if m.focus == threadDetailPane { m.scroll = 0 } else { m.threadIndex, m.scroll = 0, 0 } } func (m *App) toEnd() { if m.screen == prScreen { m.prIndex = max(0, len(m.prs)-1) } else if m.focus == threadDetailPane { m.scroll = m.detailMaxScroll() } else { m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0 } } func (m *App) page(direction int) { if m.screen == threadScreen && m.focus == threadDetailPane { m.scrollDetail(direction * max(3, m.detailViewportHeight()/2)) return } m.move(direction * max(3, m.height/2)) } func (m *App) scrollDetail(delta int) { m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll()) } func (m App) detailPaneSize() (int, int) { topLines := 3 if m.details.ThreadsTruncated { topLines++ } height := max(3, m.height-topLines-1) if m.width < 70 || m.listHidden { return max(3, m.width), height } leftWidth := m.threadListWidth() return max(20, m.width-leftWidth-1), height } func (m App) threadListWidth() int { percent := m.threadListWidthPercent if percent == 0 { percent = 33 } return clamp(m.width*percent/100, 30, max(30, m.width-20)) } func (m App) detailViewportHeight() int { _, height := m.detailPaneSize() return max(1, height-2) } func (m App) detailMaxScroll() int { width, _ := m.detailPaneSize() return max(0, len(m.detailLines(width))-m.detailViewportHeight()) } func (m App) View() string { if m.width == 0 { return "Loading…" } if m.helpVisible { return m.viewHelp() } if m.screen == prScreen { return m.viewPRs() } return m.viewThreads() } type helpBinding struct { key string action string } func (m App) helpBindings() []helpBinding { if m.screen == prScreen { return []helpBinding{ {"j / ↓", "Next pull request"}, {"k / ↑", "Previous pull request"}, {"g / G", "First / last pull request"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"enter / l", "Open pull request"}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, } } return []helpBinding{ {"h / l", "Focus thread list / detail"}, {"j / k", "Move or scroll focused pane"}, {"↓ / ↑", "Move or scroll focused pane"}, {"g / G", "First / last item"}, {"ctrl-d / ctrl-u", "Page down / up"}, {"tab", "Hide / reveal thread list"}, {"/", "Fuzzy-search file paths"}, {"enter / za", "Fold / expand thread"}, {"b / esc", "Return to pull requests"}, {"r", "Refresh now"}, {"?", "Close this help"}, {"q / ctrl-c", "Quit"}, } } func (m App) helpVisibleRows() int { return max(1, m.height-5) } func (m App) helpMaxScroll() int { return max(0, len(m.helpBindings())-m.helpVisibleRows()) } func (m App) viewHelp() string { bindings := m.helpBindings() visibleRows := m.helpVisibleRows() start := clamp(m.helpScroll, 0, max(0, len(bindings)-visibleRows)) end := min(len(bindings), start+visibleRows) contentWidth := max(1, min(70, m.width-4)) keyWidth := min(17, max(8, contentWidth/3)) title := "Pull request picker keys" if m.screen == threadScreen { title = "Review thread keys" } lines := []string{titleStyle.Render(title)} for _, binding := range bindings[start:end] { key := pad(binding.key, keyWidth) actionWidth := max(1, contentWidth-keyWidth-1) lines = append(lines, titleStyle.Render(key)+" "+truncate(binding.action, actionWidth)) } if len(bindings) > visibleRows { lines = append(lines, dimStyle.Render(fmt.Sprintf( "%d–%d of %d • j/k scroll • ?/esc/q close", start+1, end, len(bindings), ))) } else { lines = append(lines, dimStyle.Render("?/esc/q close")) } for i := range lines { lines[i] = ansi.Truncate(lines[i], contentWidth, "") } popup := paneStyle(true).Width(contentWidth).Render(strings.Join(lines, "\n")) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F")) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75")) paneInactiveColor = lipgloss.Color("#50566F") paneActiveColor = lipgloss.Color("#F0B72F") selectedLineBackground = "\x1b[48;5;24m" suggestionRemoveBackground = "\x1b[48;5;52m" suggestionAddBackground = "\x1b[48;5;22m" changedRemoveBackground = "\x1b[48;2;55;0;0m" changedAddBackground = "\x1b[48;2;0;55;0m" ) func paneStyle(active bool) lipgloss.Style { color := paneInactiveColor if active { color = paneActiveColor } return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color) } func (m App) viewPRs() string { header := titleStyle.Render("gh-threads") if m.owner != "" { header += " " + m.owner + "/" + m.repo } if m.showAll { header += dimStyle.Render(" all open pull requests") } else { header += dimStyle.Render(" assigned to you") } lines := []string{header, ""} if m.loading && len(m.prs) == 0 { lines = append(lines, "Loading open pull requests…") } else if len(m.prs) == 0 && m.err == nil { lines = append(lines, "No matching assigned pull requests.") } rows, selectedRow := groupedPRRows(m.prs, m.prIndex) available := max(1, m.height-4) start := windowStart(selectedRow, len(rows), available) for _, row := range rows[start:min(len(rows), start+available)] { if row.header { lines = append(lines, titleStyle.Render(row.repository)) continue } pr := m.prs[row.prIndex] draft := "" if pr.IsDraft { draft = " DRAFT" } titleWidth := max(10, m.width-36) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) if row.prIndex == m.prIndex { line = activeStyle.Render(line) } lines = append(lines, line) } return m.frame(lines, "? keys • j/k move • enter open • q quit") } type prListRow struct { repository string prIndex int header bool } func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) { rows := make([]prListRow, 0, len(prs)*2) selectedRow := 0 lastRepository := "" for index, pr := range prs { repository := pr.RepoWithOwner if repository == "" { repository = "(unknown repository)" } if repository != lastRepository { rows = append(rows, prListRow{repository: repository, header: true}) lastRepository = repository } if index == selected { selectedRow = len(rows) } rows = append(rows, prListRow{repository: repository, prIndex: index}) } return rows, selectedRow } func (m App) viewThreads() string { pr := m.details header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, m.width-len(pr.RepoWithOwner)-12)))) meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr)) people := "assignees: " + handlesText(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers) top := []string{header, meta, people} if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } contentHeight := max(3, m.height-len(top)-1) var body string if m.width < 70 { if m.focus == threadListPane { body = m.threadList(max(3, m.width), contentHeight) } else { body = m.threadDetail(max(3, m.width), contentHeight) } } else if m.listHidden { body = m.threadDetail(m.width, contentHeight) } else { leftWidth := m.threadListWidth() rightWidth := max(20, m.width-leftWidth-1) left := m.threadList(leftWidth, contentHeight) right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } help := "? keys • h/l focus • j/k move/scroll • b back • q quit" if m.searching { help = "type to fuzzy search • ↑/↓ choose • enter jump • esc cancel" } return m.frame(append(top, body), help) } func (m App) threadList(width, height int) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d)", len(m.details.Threads)))} if m.searching { queryWidth := max(1, innerWidth-len("Find file: ")-1) query := ansi.Truncate(m.searchQuery, queryWidth, "…") lines = append(lines, titleStyle.Render("Find file: ")+query+"█") } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") } matches := m.matchingThreadIndices() selectedPosition := 0 for position, index := range matches { if index == m.threadIndex { selectedPosition = position break } } available := max(1, innerHeight-len(lines)) start := windowStart(selectedPosition, len(matches), available) if m.searching && len(matches) == 0 { lines = append(lines, dimStyle.Render("No matching files.")) } for _, i := range matches[start:min(len(matches), start+available)] { thread := m.details.Threads[i] icon := "●" if thread.IsResolved { icon = "✓" } else if thread.IsOutdated { icon = "○" } suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments)) pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2) path := truncatePath(thread.Path, pathWidth) if m.pathScroll { path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep) } line := icon + " " + pad(path, pathWidth) + suffix line = ansi.Truncate(line, innerWidth, "") if thread.IsResolved { line = dimStyle.Render(line) } if i == m.threadIndex { line = activeStyle.Render(pad(line, innerWidth)) } lines = append(lines, line) } return renderPane(lines, width, height, m.focus == threadListPane) } func (m App) threadDetail(width, height int) string { if len(m.details.Threads) == 0 { return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane) } lines := m.detailLines(width) viewportHeight := max(1, height-2) maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] rendered := make([]string, 0, len(visible)) innerWidth := max(1, width-2) for _, line := range visible { railWidth := ansi.StringWidth(line.rail) contentWidth := max(1, innerWidth-railWidth) var renderedLine string if line.suggestionChange != 0 { renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange) } else { renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "…") } renderedLine = line.rail + renderedLine if line.selected { renderedLine = selectedBackground(renderedLine, innerWidth) } rendered = append(rendered, renderedLine) } return renderPane(rendered, width, height, m.focus == threadDetailPane) } type detailLine struct { text string fixed string rail string selected bool suggestionChange byte } func (m App) detailLines(width int) []detailLine { if len(m.details.Threads) == 0 { return nil } thread := m.details.Threads[m.threadIndex] status := "open" if thread.IsResolved { status = "resolved" } if thread.IsOutdated { status += ", outdated" } if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" { status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID) } lines := []detailLine{ {text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, } if m.folded[thread.ID] { lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")}) } else { if len(thread.Comments) > 0 { lines = append(lines, detailLine{}) startLine, endLine := reviewAnchor(thread) for _, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) { lines = append(lines, wrapDiffLine(codeLine, max(1, width-2))...) } } for _, comment := range thread.Comments { content := parseCommentBody(comment.Body) rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ") lines = append(lines, detailLine{}, detailLine{ rail: rail, text: authorStyle(comment.Author).Render("@"+comment.Author) + " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")), }) if content.Prose != "" { for _, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) { lines = append(lines, detailLine{rail: rail, text: commentLine}) } } for suggestionIndex, suggestion := range content.Suggestions { removed, added := normalizeSuggestion(reviewedSourceLines(thread, comment), suggestion) removedRanges, addedRanges := suggestionChangedRanges(removed, added) label := "suggested change" if len(content.Suggestions) > 1 { label = fmt.Sprintf("suggested change %d/%d", suggestionIndex+1, len(content.Suggestions)) } lines = append(lines, detailLine{rail: rail}, detailLine{rail: rail, text: dimStyle.Render(label)}) for sourceIndex, source := range removed { lines = append(lines, addCommentRail( wrapSuggestionLine( thread.Path, source, '-', removedRanges[sourceIndex], max(1, width-4), ), rail, )...) } for sourceIndex, source := range added { lines = append(lines, addCommentRail( wrapSuggestionLine( thread.Path, source, '+', addedRanges[sourceIndex], max(1, width-4), ), rail, )...) } } } if thread.IsTruncated { lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")}) } } return lines } func wrapDiffLine(line highlightedDiffLine, width int) []detailLine { gutterWidth := ansi.StringWidth(line.gutter) if gutterWidth == 0 { wrapped := ansi.Hardwrap(line.code, width, true) parts := strings.Split(wrapped, "\n") result := make([]detailLine, 0, len(parts)) for _, part := range parts { result = append(result, detailLine{text: part, selected: line.selected}) } return result } codeWidth := max(1, width-gutterWidth) parts := wrapCodeWithIndent(line.code, codeWidth) result := make([]detailLine, 0, len(parts)) for i, part := range parts { gutter := line.gutter if i > 0 { gutter = strings.Repeat(" ", gutterWidth) } result = append(result, detailLine{ text: part, fixed: gutter, selected: line.selected, }) } return result } func wrapSuggestionLine(path, source string, change byte, changed codeRange, width int) []detailLine { marker := badStyle.Render(string(change)) if change == '+' { marker = okStyle.Render(string(change)) } code := highlightedSource(lexerForPath(path), source) code = highlightCodeRange(code, changed, change) lines := wrapDiffLine(highlightedDiffLine{ gutter: marker + " ", code: code, }, width) for i := range lines { lines[i].suggestionChange = change } return lines } func highlightCodeRange(code string, changed codeRange, change byte) string { if changed.End <= changed.Start { return code } totalWidth := ansi.StringWidth(code) start := clamp(changed.Start, 0, totalWidth) end := clamp(changed.End, start, totalWidth) if end <= start { return code } background := changedRemoveBackground if change == '+' { background = changedAddBackground } const reset = "\x1b[0m" before := ansi.Cut(code, 0, start) middle := ansi.Cut(code, start, end) after := ansi.Cut(code, end, totalWidth) middle = strings.ReplaceAll(middle, reset, reset+background) return before + background + middle + reset + after } func addCommentRail(lines []detailLine, rail string) []detailLine { for i := range lines { lines[i].rail = rail } return lines } func wrapCodeWithIndent(code string, width int) []string { if width <= 0 || ansi.StringWidth(code) <= width { return []string{code} } plain := ansi.Strip(code) indent := len(plain) - len(strings.TrimLeft(plain, " ")) totalWidth := ansi.StringWidth(code) continuationPrefix := strings.Repeat(" ", indent+2) + dimStyle.Render("↳ ") continuationWidth := max(1, width-ansi.StringWidth(continuationPrefix)) parts := make([]string, 0, totalWidth/width+1) offset := 0 for offset < totalWidth { available := width prefix := "" if offset > 0 { available = continuationWidth prefix = continuationPrefix } end := syntaxBreakColumn(plain, offset, available) if end <= offset { end = min(totalWidth, offset+available) } parts = append(parts, prefix+ansi.Cut(code, offset, end)) offset = end } return parts } func syntaxBreakColumn(code string, start, width int) int { limit := start + width column := 0 best := -1 for _, r := range code { column += ansi.StringWidth(string(r)) if column <= start { continue } if column > limit { break } if isCodeBreakpoint(r) { best = column } } if best > start { return best } return min(ansi.StringWidth(code), limit) } func isCodeBreakpoint(r rune) bool { if r == ' ' || r == '\t' { return true } return strings.ContainsRune(",.;:()[]{}+-*/%=<>|&", r) } func reviewAnchor(thread ReviewThread) (int, int) { if len(thread.Comments) > 0 { return commentReviewAnchor(thread, thread.Comments[0]) } return commentReviewAnchor(thread, ReviewComment{}) } func shortOID(oid string) string { if len(oid) <= 7 { return oid } return oid[:7] } func selectedBackground(line string, width int) string { const reset = "\x1b[0m" background := selectedLineBackground line = pad(ansi.Truncate(line, width, ""), width) line = strings.ReplaceAll(line, reset, reset+background) return background + line + reset } func suggestionHighlight(gutter, code string, width int, change byte) string { background := suggestionRemoveBackground if change == '+' { background = suggestionAddBackground } const reset = "\x1b[0m" gutter = ansi.Truncate(gutter, width, "") gutter = strings.ReplaceAll(gutter, reset, reset+background) codeWidth := max(0, width-ansi.StringWidth(gutter)) code = ansi.Truncate(code, codeWidth, "") code = strings.ReplaceAll(code, reset, reset+background) content := background + gutter + code + reset padding := max(0, width-ansi.StringWidth(gutter)-ansi.StringWidth(code)) if padding > 0 { content += background + strings.Repeat(" ", padding) + reset } return content } var authorPalette = []lipgloss.Color{ "#61AFEF", "#C678DD", "#56B6C2", "#E5C07B", "#E06C75", "#98C379", "#D19A66", "#7FC8FF", } func authorStyle(login string) lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login)) } func authorColor(login string) lipgloss.Color { hash := fnv.New32a() _, _ = hash.Write([]byte(strings.ToLower(login))) return authorPalette[int(hash.Sum32())%len(authorPalette)] } func (m App) frame(lines []string, help string) string { body := strings.Join(lines, "\n") status := "" if m.err != nil { status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8))) } if m.loading { status = warnStyle.Render("refreshing…") } if status == "" && !m.lastRefresh.IsZero() { status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05")) } footer := truncate(help, m.width) if status != "" { footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status } bodyLines := strings.Split(body, "\n") if len(bodyLines) > max(0, m.height-1) { bodyLines = bodyLines[:max(0, m.height-1)] } for i := range bodyLines { bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "") } bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, "")) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) } func renderPane(lines []string, width, height int, active bool) string { innerWidth := max(1, width-2) innerHeight := max(1, height-2) if len(lines) > innerHeight { lines = lines[:innerHeight] } for i := range lines { lines[i] = ansi.Truncate(lines[i], innerWidth, "") } return paneStyle(active).Width(innerWidth).Height(innerHeight).Render(strings.Join(lines, "\n")) } func reviewAndMergeState(pr PRDetails) string { switch pr.ReviewDecision { case "APPROVED": review := okStyle.Render("review: approved") switch pr.Mergeable { case "MERGEABLE": return review + " " + okStyle.Render("merge: ready") case "CONFLICTING": return review + " " + badStyle.Render("merge: conflicts") default: return review + " " + warnStyle.Render("merge: checking") } case "CHANGES_REQUESTED": return badStyle.Render("review: changes requested") case "REVIEW_REQUIRED": return warnStyle.Render("review: required") default: return warnStyle.Render("review: pending") } } func coloredState(state string) string { switch state { case "SUCCESS", "EXPECTED": return okStyle.Render(state) case "FAILURE", "ERROR": return badStyle.Render(state) default: return warnStyle.Render(state) } } func reviewersText(reviewers []Reviewer) string { if len(reviewers) == 0 { return "none" } items := make([]string, 0, len(reviewers)) for _, reviewer := range reviewers { items = append(items, authorStyle(reviewer.Login).Render("@"+reviewer.Login)+ dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"), ) } return strings.Join(items, ", ") } func handlesText(items []string) string { if len(items) == 0 { return "none" } handles := make([]string, 0, len(items)) for _, item := range items { handles = append(handles, authorStyle(item).Render("@"+item)) } return strings.Join(handles, ", ") } func indexPR(items []PullRequest, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func indexThread(items []ReviewThread, id string) int { for i, item := range items { if item.ID == id { return i } } return 0 } func clamp(value, low, high int) int { if high < low { return low } return min(max(value, low), high) } func windowStart(index, count, size int) int { if count <= size { return 0 } return clamp(index-size/2, 0, count-size) } func truncate(s string, width int) string { if width <= 0 { return "" } return ansi.Truncate(strings.ReplaceAll(s, "\n", " "), width, "…") } func truncatePath(path string, width int) string { if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth) } func scrollingPath(path string, width, step int) string { const pauseSteps = 4 if width <= 0 { return "" } pathWidth := ansi.StringWidth(path) if pathWidth <= width { return path } if width == 1 { return "…" } visibleWidth := width - 1 maxOffset := pathWidth - visibleWidth cycle := pauseSteps + maxOffset + pauseSteps phase := step % cycle offset := 0 switch { case phase < pauseSteps: offset = 0 case phase < pauseSteps+maxOffset: offset = phase - pauseSteps default: offset = maxOffset } if offset == 0 { return ansi.Cut(path, 0, visibleWidth) + "…" } if offset == maxOffset { return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth) } return "…" + ansi.Cut(path, offset, offset+width-2) + "…" } func fuzzyPathScore(path, query string) (int, bool) { candidate := []rune(strings.ToLower(path)) terms := strings.Fields(strings.ToLower(query)) if len(terms) == 0 { return 0, true } total := 0 for _, term := range terms { score, ok := fuzzyTermScore(candidate, []rune(term)) if !ok { return 0, false } total += score } return total, true } func fuzzyTermScore(candidate, needle []rune) (int, bool) { if start := strings.Index(string(candidate), string(needle)); start >= 0 { return 10000 - start*10 - len(candidate), true } score, candidateIndex, previous := 0, 0, -2 for _, wanted := range needle { found := -1 for candidateIndex < len(candidate) { if candidate[candidateIndex] == wanted { found = candidateIndex candidateIndex++ break } candidateIndex++ } if found < 0 { return 0, false } score += 20 if found == previous+1 { score += 15 } if found == 0 || strings.ContainsRune("/._-", candidate[found-1]) { score += 12 } score -= found previous = found } return score - len(candidate), true } func pad(s string, width int) string { n := width - lipgloss.Width(s) if n <= 0 { return truncate(s, width) } return s + strings.Repeat(" ", n) } func wrap(text string, width int) string { words := strings.Fields(text) if len(words) == 0 { return "" } lines, line := []string{}, words[0] for _, word := range words[1:] { if len([]rune(line))+1+len([]rune(word)) > width { lines, line = append(lines, line), word } else { line += " " + word } } return strings.Join(append(lines, line), "\n") }