Improve highlighting and add QoL changes

This commit is contained in:
2026-07-27 14:28:32 +02:00
parent 6bfe248388
commit 547cd123da
11 changed files with 989 additions and 109 deletions

400
tui.go
View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"hash/fnv"
"sort"
"strings"
"time"
@@ -32,6 +33,8 @@ type prsLoadedMsg struct {
err error
}
type detailsLoadedMsg struct {
owner string
repo string
number int
details PRDetails
err error
@@ -58,6 +61,11 @@ type App struct {
err error
lastRefresh time.Time
pendingZ bool
searching bool
searchQuery string
searchOrigin int
helpVisible bool
helpScroll int
}
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
@@ -84,12 +92,15 @@ func (m App) loadPRs() tea.Cmd {
}
}
func (m App) loadDetails(number int) tea.Cmd {
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, m.owner, m.repo, number)
return detailsLoadedMsg{number: number, details: details, err: err}
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,
}
}
}
@@ -101,7 +112,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.loadDetails(m.details.PullRequest), m.nextTick())
}
return m, tea.Batch(m.loadPRs(), m.nextTick())
}
@@ -112,17 +123,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err
return m, nil
}
selected := 0
selected := ""
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
selected = m.prs[m.prIndex].Number
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 msg.number != m.details.Number && m.details.Number != 0 {
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 {
@@ -153,9 +172,66 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -170,13 +246,18 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.Number)
return m, m.loadDetails(m.details.PullRequest)
}
return m, m.loadPRs()
case "j", "down":
@@ -214,7 +295,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
m.searching, m.searchQuery = false, ""
return m, m.loadDetails(m.details.PullRequest)
}
if m.screen == threadScreen {
m.focus = threadDetailPane
@@ -229,7 +311,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
m.searching, m.searchQuery = false, ""
return m, m.loadDetails(m.details.PullRequest)
}
if m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex]
@@ -239,6 +322,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
}
}
@@ -254,6 +338,59 @@ func (m *App) move(delta int) {
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
@@ -311,12 +448,90 @@ 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"))
@@ -335,38 +550,76 @@ func paneStyle(active bool) lipgloss.Style {
}
func (m App) viewPRs() string {
header := titleStyle.Render("gh-threads") + " " + m.owner + "/" + m.repo
if !m.showAll {
header += dimStyle.Render(" authored by you")
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 open pull requests.")
lines = append(lines, "No matching assigned 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]
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"
}
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 {
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, "j/k move • enter/l open • g/G top/bottom • r refresh • q quit")
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("#%d %s", pr.Number, truncate(pr.Title, max(10, m.width-10))))
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: " + joinOrNone(pr.Assignees) + " reviewers: " + reviewersText(pr.Reviewers)
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."))
@@ -389,25 +642,44 @@ func (m App) viewThreads() string {
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")
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…")
}
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++ {
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 = "✓"
}
if thread.IsOutdated {
} else if thread.IsOutdated {
icon = "○"
}
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
@@ -499,7 +771,7 @@ func (m App) detailLines(width int) []detailLine {
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") {
for _, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) {
lines = append(lines, detailLine{rail: rail, text: commentLine})
}
}
@@ -801,19 +1073,26 @@ func reviewersText(reviewers []Reviewer) string {
}
items := make([]string, 0, len(reviewers))
for _, reviewer := range reviewers {
items = append(items, reviewer.Login+"("+strings.ToLower(reviewer.State)+")")
items = append(items,
authorStyle(reviewer.Login).Render("@"+reviewer.Login)+
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
)
}
return strings.Join(items, ", ")
}
func joinOrNone(items []string) string {
func handlesText(items []string) string {
if len(items) == 0 {
return "none"
}
return strings.Join(items, ", ")
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, number int) int {
func indexPR(items []PullRequest, id string) int {
for i, item := range items {
if item.Number == number {
if item.ID == id {
return i
}
}
@@ -858,6 +1137,57 @@ func truncatePath(path string, width int) string {
}
return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth)
}
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 {