Improve highlighting and add QoL changes
This commit is contained in:
400
tui.go
400
tui.go
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user