Fix high prio recommendations, add error / health screen

This commit is contained in:
2026-07-28 15:40:11 +02:00
parent 948f3e1a79
commit 7511311297
19 changed files with 1885 additions and 148 deletions

511
tui.go
View File

@@ -21,6 +21,7 @@ const (
prScreen screen = iota
dashboardScreen
threadScreen
healthScreen
)
type pane int
@@ -65,17 +66,19 @@ type pullRequestUpdatedMsg struct {
}
type prsLoadedMsg struct {
prs []PullRequest
err error
cached bool
prs []PullRequest
err error
cached bool
requestID uint64
}
type detailsLoadedMsg struct {
owner string
repo string
number int
details PRDetails
err error
cached bool
owner string
repo string
number int
details PRDetails
err error
cached bool
requestID uint64
}
type branchesLoadedMsg struct {
@@ -85,6 +88,11 @@ type branchesLoadedMsg struct {
err error
}
type detailsEnrichedMsg struct {
enrichment PRDetailsEnrichment
requestID uint64
}
type App struct {
service GitHubService
owner, repo string
@@ -93,6 +101,10 @@ type App struct {
poll time.Duration
screen screen
healthReturn screen
healthScroll int
healthEvents []HealthEvent
requests *requestCoordinator
prs []PullRequest
prIndex int
details PRDetails
@@ -103,6 +115,7 @@ type App struct {
scroll int
width, height int
loading bool
secondaryLoading bool
err error
lastRefresh time.Time
pendingZ bool
@@ -137,6 +150,7 @@ type App struct {
editorMode string
keybindings KeyBindings
readState *readStateStore
drafts *draftStore
knownThreads map[string]bool
knownComments map[string]bool
initializedPRs map[string]bool
@@ -156,6 +170,7 @@ type AppSettings struct {
EditorMode string
KeyBindings KeyBindings
ReadState *readStateStore
Drafts *draftStore
}
func defaultAppSettings() AppSettings {
@@ -196,10 +211,13 @@ func NewAppWithSettings(
threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...),
threadWithinStatus: settings.ThreadWithinStatus,
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
healthReturn: prScreen,
requests: &requestCoordinator{},
compactReviews: settings.CompactReviews,
editorMode: settings.EditorMode,
keybindings: settings.KeyBindings,
readState: state,
drafts: settings.Drafts,
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
updatedThreads: make(map[string]bool),
@@ -215,7 +233,33 @@ func (m App) Init() tea.Cmd {
}
func (m App) nextTick() tea.Cmd {
return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) })
return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m App) adaptivePollInterval(now time.Time) time.Duration {
interval := m.poll
if provider, ok := m.service.(healthProvider); ok {
rate := provider.RateLimit()
switch {
case now.Before(rate.RetryAfter):
interval = max(interval, rate.RetryAfter.Sub(now))
case rate.Limit > 0 && rate.Remaining*20 < rate.Limit:
interval *= 8
case rate.Limit > 0 && rate.Remaining*100 < rate.Limit*15:
interval *= 4
case rate.Limit > 0 && rate.Remaining*10 < rate.Limit*3:
interval *= 2
}
}
interval = min(interval, 15*time.Minute)
// Stable-enough per-call jitter prevents synchronized clients without
// introducing a shared random source into the model.
jitter := interval / 10
if jitter > 0 {
offset := time.Duration(now.UnixNano()%int64(2*jitter+1)) - jitter
interval += offset
}
return max(interval, 2*time.Second)
}
func (m App) nextPathTick() tea.Cmd {
@@ -235,7 +279,7 @@ func (m App) loadPRs(useCache bool) tea.Cmd {
func (m App) loadLivePRs() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
ctx, cancel, requestID := m.requests.start(20 * time.Second)
defer cancel()
var prs []PullRequest
var err error
@@ -244,7 +288,7 @@ func (m App) loadLivePRs() tea.Cmd {
} else {
prs, err = m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll)
}
return prsLoadedMsg{prs: prs, err: err}
return prsLoadedMsg{prs: prs, err: err, requestID: requestID}
}
}
@@ -264,7 +308,7 @@ func (m App) loadDetails(pr PullRequest, useCache bool) tea.Cmd {
func (m App) loadLiveDetails(pr PullRequest) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel, requestID := m.requests.start(60 * time.Second)
defer cancel()
var details PRDetails
var err error
@@ -275,7 +319,21 @@ func (m App) loadLiveDetails(pr PullRequest) tea.Cmd {
}
return detailsLoadedMsg{
owner: pr.Owner, repo: pr.Repository, number: pr.Number,
details: details, err: err,
details: details, err: err, requestID: requestID,
}
}
}
func (m App) loadDetailsEnrichment(details PRDetails) tea.Cmd {
service, ok := m.service.(GitHubEnrichmentService)
if !ok {
return nil
}
return func() tea.Msg {
ctx, cancel, requestID := m.requests.start(60 * time.Second)
defer cancel()
return detailsEnrichedMsg{
enrichment: service.EnrichPullRequest(ctx, details), requestID: requestID,
}
}
}
@@ -287,6 +345,7 @@ func (m *App) startReply() {
return
}
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
m.restoreReplyDraft(thread.ID)
m.folded[thread.ID] = false
m.focus = threadDetailPane
m.scroll = m.detailMaxScroll()
@@ -365,6 +424,7 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
if m.writeMode == writeReply {
m.scroll = m.detailMaxScroll()
return m, m.queueReplyDraft()
}
case writeReplyConfirm:
switch k {
@@ -418,7 +478,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg:
if !m.loading {
m.loading = true
if (m.screen == dashboardScreen || m.screen == threadScreen) && m.details.Number != 0 {
targetScreen := m.screen
if targetScreen == healthScreen {
targetScreen = m.healthReturn
}
if (targetScreen == dashboardScreen || targetScreen == threadScreen) && m.details.Number != 0 {
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.nextTick())
}
return m, tea.Batch(m.loadPRs(false), m.nextTick())
@@ -433,7 +497,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case prsLoadedMsg:
if m.screen != prScreen || msg.cached && !m.loading {
if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil
}
if (m.screen != prScreen && !(m.screen == healthScreen && m.healthReturn == prScreen)) ||
msg.cached && !m.loading {
return m, nil
}
if !msg.cached {
@@ -441,9 +509,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.err != nil {
if msg.cached {
m.recordHealth("pull request cache", healthWarning, msg.err.Error())
return m, nil
}
m.err = msg.err
m.recordHealth("pull request list", healthError, msg.err.Error())
return m, nil
}
selected := ""
@@ -466,7 +536,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastRefresh = time.Now()
}
case detailsLoadedMsg:
if m.screen != dashboardScreen && m.screen != threadScreen {
if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil
}
if m.screen != dashboardScreen && m.screen != threadScreen &&
!(m.screen == healthScreen &&
(m.healthReturn == dashboardScreen || m.healthReturn == threadScreen)) {
return m, nil
}
if msg.cached && !m.loading {
@@ -481,9 +556,11 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.err != nil {
if msg.cached {
m.recordHealth("PR cache", healthWarning, msg.err.Error())
return m, nil
}
m.err = msg.err
m.recordHealth("PR refresh", healthError, msg.err.Error())
return m, nil
}
selected := ""
@@ -492,9 +569,16 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
selected = m.details.Threads[m.threadIndex].ID
anchor = m.detailScrollAnchor()
}
msg.details = preservePartialPRData(msg.details, m.details)
m.trackThreadUpdates(msg.details)
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.details = msg.details
for _, issue := range msg.details.DataIssues {
m.recordHealth(issue.Component, healthWarning, issue.Message)
}
if msg.details.ConflictFileError != "" {
m.recordHealth("conflict file scan", healthWarning, msg.details.ConflictFileError)
}
m.threadIndex = indexThread(m.details.Threads, selected)
if selected != "" && (len(m.details.Threads) == 0 || m.details.Threads[m.threadIndex].ID != selected) {
m.scroll = 0
@@ -518,6 +602,45 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastRefresh = msg.details.CachedAt
} else {
m.lastRefresh = time.Now()
if _, ok := m.service.(GitHubEnrichmentService); ok {
m.secondaryLoading = true
return m, m.loadDetailsEnrichment(msg.details)
}
}
case detailsEnrichedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil
}
enrichment := msg.enrichment
if enrichment.Owner != m.details.Owner ||
enrichment.Repository != m.details.Repository ||
enrichment.Number != m.details.Number ||
enrichment.HeadOID != m.details.HeadOID {
return m, nil
}
m.secondaryLoading = false
m.details.DataIssues = removeDataIssues(
m.details.DataIssues, "check annotations", "conflict file scan",
)
for index := range m.details.Checks {
if annotations, ok := enrichment.CheckAnnotations[m.details.Checks[index].ID]; ok {
m.details.Checks[index].Annotations = annotations
}
}
if enrichment.ConflictFiles != nil {
m.details.ConflictFiles = enrichment.ConflictFiles
m.details.ConflictFileError = ""
}
for _, issue := range enrichment.Issues {
m.details.DataIssues = append(m.details.DataIssues, issue)
if issue.Component == "conflict file scan" {
m.details.ConflictFileError = issue.Message
}
m.recordHealth(issue.Component, healthWarning, issue.Message)
}
case draftFlushMsg:
if msg.err != nil {
m.recordHealth("draft persistence", healthWarning, msg.err.Error())
}
case branchesLoadedMsg:
if m.writeMode != writePREdit ||
@@ -527,6 +650,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.prEditBranchesLoading = false
if msg.err != nil {
m.prEditBranchesError = msg.err.Error()
m.recordHealth("branch recommendations", healthWarning, msg.err.Error())
m.ensurePREditCursorVisible()
return m, nil
}
@@ -538,6 +662,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.writeMode = writeNone
if msg.err != nil {
m.err = fmt.Errorf("change thread resolution: %w", msg.err)
m.recordHealth("thread resolution", healthError, msg.err.Error())
return m, nil
}
selected := ""
@@ -566,6 +691,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.writeMode = writeReply
m.err = fmt.Errorf("reply to review thread: %w", msg.err)
m.recordHealth("thread reply", healthError, msg.err.Error())
m.scroll = m.detailMaxScroll()
return m, nil
}
@@ -578,6 +704,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break
}
}
draftKey := replyDraftKey(
m.details.Owner, m.details.Repository, m.details.Number, msg.threadID,
)
if err := m.drafts.delete(draftKey); err != nil {
m.recordHealth("draft persistence", healthWarning, err.Error())
}
m.replyDraft, m.writeThreadID = "", ""
m.err = nil
m.lastRefresh = time.Now()
@@ -585,6 +717,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.writeMode = writePREdit
m.err = fmt.Errorf("update pull request: %w", msg.err)
m.recordHealth("PR metadata update", healthError, msg.err.Error())
m.scroll = 0
return m, nil
}
@@ -604,6 +737,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break
}
}
draftKey := prMetadataDraftKey(
m.details.Owner, m.details.Repository, m.details.Number,
)
if err := m.drafts.delete(draftKey); err != nil {
m.recordHealth("draft persistence", healthWarning, err.Error())
}
m.clearPREdit()
m.err = nil
m.lastRefresh = time.Now()
@@ -739,13 +878,19 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.loading = true
if m.screen == dashboardScreen || m.screen == threadScreen {
targetScreen := m.screen
if targetScreen == healthScreen {
targetScreen = m.healthReturn
}
if targetScreen == dashboardScreen || targetScreen == threadScreen {
return m, m.loadDetails(m.details.PullRequest, false)
}
return m, m.loadPRs(false)
case "j", "down":
if m.screen == dashboardScreen {
m.scrollDashboard(1)
} else if m.screen == healthScreen {
m.healthScroll = clamp(m.healthScroll+1, 0, m.healthMaxScroll())
} else if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(1)
} else {
@@ -754,6 +899,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "k", "up":
if m.screen == dashboardScreen {
m.scrollDashboard(-1)
} else if m.screen == healthScreen {
m.healthScroll = clamp(m.healthScroll-1, 0, m.healthMaxScroll())
} else if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(-1)
} else {
@@ -793,6 +940,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.dashboardReturn, m.screen, m.scroll = threadScreen, dashboardScreen, 0
return m, nil
}
case "H":
if m.screen != healthScreen {
m.healthReturn, m.screen, m.healthScroll = m.screen, healthScreen, 0
}
case "l":
if m.screen == prScreen && len(m.prs) > 0 {
return m, m.openSelectedPR(m.defaultPRTargetScreen())
@@ -825,6 +976,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.scroll = 0
}
case "b", "esc":
if m.screen == healthScreen {
m.screen = m.healthReturn
return m, nil
}
if m.screen == threadScreen {
if m.dashboardMode == "intermediate" {
m.dashboardReturn, m.screen, m.scroll, m.err = prScreen, dashboardScreen, 0, nil
@@ -846,6 +1001,78 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func preservePartialPRData(fresh, previous PRDetails) PRDetails {
if previous.Number == 0 ||
fresh.Owner != previous.Owner ||
fresh.Repository != previous.Repository ||
fresh.Number != previous.Number {
return fresh
}
if fresh.HeadOID != "" && fresh.HeadOID == previous.HeadOID &&
fresh.BaseOID == previous.BaseOID {
if fresh.ConflictFiles == nil {
fresh.ConflictFiles = previous.ConflictFiles
fresh.ConflictFileError = previous.ConflictFileError
}
annotations := make(map[string][]CheckAnnotation, len(previous.Checks))
for _, check := range previous.Checks {
if len(check.Annotations) > 0 {
annotations[check.ID] = check.Annotations
}
}
for index := range fresh.Checks {
if len(fresh.Checks[index].Annotations) == 0 {
fresh.Checks[index].Annotations = annotations[fresh.Checks[index].ID]
}
}
for _, issue := range previous.DataIssues {
if issue.Component == "check annotations" ||
issue.Component == "conflict file scan" {
fresh.DataIssues = append(fresh.DataIssues, issue)
}
}
}
for _, issue := range fresh.DataIssues {
switch issue.Component {
case "review threads":
if len(previous.Threads) > len(fresh.Threads) {
fresh.Threads = previous.Threads
}
case "conversation":
if len(previous.Conversation) > len(fresh.Conversation) {
fresh.Conversation = previous.Conversation
}
case "submitted reviews":
if len(previous.Reviews) > len(fresh.Reviews) {
fresh.Reviews = previous.Reviews
}
case "timeline":
if len(previous.Timeline) > len(fresh.Timeline) {
fresh.Timeline = previous.Timeline
}
case "checks":
if len(previous.Checks) > len(fresh.Checks) {
fresh.Checks = previous.Checks
}
}
}
return fresh
}
func removeDataIssues(issues []DataIssue, components ...string) []DataIssue {
removed := make(map[string]bool, len(components))
for _, component := range components {
removed[component] = true
}
filtered := issues[:0]
for _, issue := range issues {
if !removed[issue.Component] {
filtered = append(filtered, issue)
}
}
return filtered
}
func (m App) defaultPRTargetScreen() screen {
if m.dashboardMode == "hotkey" {
return threadScreen
@@ -888,7 +1115,9 @@ func (m *App) trackThreadUpdates(details PRDetails) {
}
state.Initialized = true
m.readState.Data[prID] = state
_ = m.readState.save()
if err := m.readState.save(); err != nil {
m.recordHealth("read state", healthWarning, err.Error())
}
return
}
for _, thread := range details.Threads {
@@ -924,7 +1153,9 @@ func (m *App) markCurrentThreadRead() {
state.Comments[comment.ID] = true
}
m.readState.Data[prID] = state
_ = m.readState.save()
if err := m.readState.save(); err != nil {
m.recordHealth("read state", healthWarning, err.Error())
}
}
}
@@ -1166,6 +1397,8 @@ func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) {
func (m *App) toStart() {
if m.screen == prScreen {
m.prIndex = 0
} else if m.screen == healthScreen {
m.healthScroll = 0
} else if m.screen == dashboardScreen {
m.scroll = 0
} else if m.focus == threadDetailPane {
@@ -1179,6 +1412,8 @@ func (m *App) toEnd() {
m.prIndex = max(0, len(m.prs)-1)
} else if m.screen == dashboardScreen {
m.scroll = m.dashboardMaxScroll()
} else if m.screen == healthScreen {
m.healthScroll = m.healthMaxScroll()
} else if m.focus == threadDetailPane {
m.scroll = m.detailMaxScroll()
} else {
@@ -1190,6 +1425,14 @@ func (m *App) page(direction int) {
m.scrollDashboard(direction * max(3, m.dashboardViewportHeight()/2))
return
}
if m.screen == healthScreen {
m.healthScroll = clamp(
m.healthScroll+direction*max(3, m.healthViewportHeight()/2),
0,
m.healthMaxScroll(),
)
return
}
if m.screen == threadScreen && m.focus == threadDetailPane {
m.scrollDetail(direction * max(3, m.detailViewportHeight()/2))
return
@@ -1202,7 +1445,8 @@ func (m *App) scrollDetail(delta int) {
}
func (m *App) scrollDashboard(delta int) {
m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll())
maxScroll := m.dashboardMaxScroll()
m.scroll = clamp(m.scroll+delta, 0, maxScroll)
}
func (m App) dashboardViewportHeight() int {
@@ -1266,6 +1510,9 @@ func (m App) View() string {
if m.screen == dashboardScreen {
return m.viewDashboard()
}
if m.screen == healthScreen {
return m.viewHealth()
}
return m.viewThreads()
}
@@ -1449,6 +1696,18 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.General.Quit), "Quit"},
}
}
if m.screen == healthScreen {
return []helpBinding{
{keyLabel(m.keybindings.Navigation.Down), "Scroll health details down"},
{keyLabel(m.keybindings.Navigation.Up), "Scroll health details up"},
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
{keyLabel(m.keybindings.General.Refresh), "Refresh application data"},
{keyLabel(m.keybindings.General.Back), "Return to the previous screen"},
{keyLabel(m.keybindings.General.Help), "Close this help"},
{keyLabel(m.keybindings.General.Quit), "Quit"},
}
}
if m.screen == dashboardScreen {
backAction := "Return to pull requests"
if m.dashboardReturn == threadScreen {
@@ -1516,6 +1775,8 @@ func (m App) viewHelp() string {
title := "Pull request picker keys"
if m.writeMode == writePREdit || m.writeMode == writePREditConfirm {
title = "Pull request editor keys"
} else if m.screen == healthScreen {
title = "Application health keys"
} else if m.screen == dashboardScreen {
title = "Pull request dashboard keys"
} else if m.screen == threadScreen {
@@ -1741,6 +2002,200 @@ func (m App) viewDashboard() string {
return view
}
func (m App) viewHealth() string {
lines := m.healthLines()
viewportHeight := m.healthViewportHeight()
start := clamp(m.healthScroll, 0, max(0, len(lines)-viewportHeight))
end := min(len(lines), start+viewportHeight)
footer := fmt.Sprintf(
"%s keys • %s scroll • %s refresh • %s close",
primaryKeyLabel(m.keybindings.General.Help),
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
primaryKeyLabel(m.keybindings.General.Refresh),
primaryKeyLabel(m.keybindings.General.Back),
)
contentWidth := m.healthContentWidth()
border := lipgloss.NewStyle().Foreground(paneActiveColor)
boxLines := []string{
border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"),
border.Render("│") + pad(titleStyle.Render("Application health"), contentWidth) + border.Render("│"),
border.Render("├" + strings.Repeat("─", contentWidth) + "┤"),
}
for _, line := range lines[start:end] {
boxLines = append(boxLines,
border.Render("│")+pad(ansi.Truncate(line, contentWidth, ""), contentWidth)+border.Render("│"),
)
}
for len(boxLines) < viewportHeight+3 {
boxLines = append(boxLines,
border.Render("│")+strings.Repeat(" ", contentWidth)+border.Render("│"),
)
}
boxLines = append(boxLines,
border.Render("├"+strings.Repeat("─", contentWidth)+"┤"),
border.Render("│")+pad(dimStyle.Render(ansi.Truncate(footer, contentWidth, "")), contentWidth)+border.Render("│"),
border.Render("╰"+strings.Repeat("─", contentWidth)+"╯"),
)
popup := strings.Join(boxLines, "\n")
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
}
func (m App) healthMaxScroll() int {
return max(0, len(m.healthLines())-m.healthViewportHeight())
}
func (m App) healthContentWidth() int {
return max(18, min(96, m.width-4))
}
func (m App) healthViewportHeight() int {
// Border, title, divider, footer divider, footer, and closing border.
return max(1, min(28, m.height-6))
}
func (m App) healthLines() []string {
width := m.healthContentWidth()
lines := []string{}
components := []HealthComponent{{
Name: "application", Level: healthOK, Summary: "interactive loop is running",
UpdatedAt: time.Now(),
}}
refresh := HealthComponent{
Name: "refresh", Level: healthOK, Summary: "idle",
UpdatedAt: m.lastRefresh,
}
if m.loading {
refresh.Level = healthInfo
refresh.Summary = "core refresh in progress"
} else if m.secondaryLoading {
refresh.Level = healthInfo
refresh.Summary = "core data ready; secondary enrichment in progress"
}
components = append(components, refresh)
components = append(components, HealthComponent{
Name: "configuration", Level: healthOK, Summary: "configuration loaded and validated",
})
if m.readState != nil {
component := HealthComponent{
Name: "read state", Level: healthOK, Summary: "persistent unread state is available",
Detail: m.readState.path,
}
if m.readState.loadErr != nil {
component.Level = healthWarning
component.Summary = "state recovery started with an empty store"
component.Detail = m.readState.loadErr.Error()
}
components = append(components, component)
}
if m.drafts != nil {
component := HealthComponent{
Name: "draft persistence", Level: healthOK, Summary: "draft recovery is available",
Detail: m.drafts.path,
}
if m.drafts.loadErr != nil {
component.Level = healthWarning
component.Summary = "draft recovery file could not be loaded"
component.Detail = m.drafts.loadErr.Error()
}
components = append(components, component)
}
if m.details.ID != "" {
core := HealthComponent{
Name: "PR core data", Level: healthOK,
Summary: "pull request and review data loaded",
UpdatedAt: m.details.UpdatedAt,
}
if len(m.details.DataIssues) > 0 {
core.Level = healthWarning
core.Summary = fmt.Sprintf("%d data subsection(s) are partial", len(m.details.DataIssues))
}
components = append(components, core)
enrichment := HealthComponent{
Name: "PR enrichment", Level: healthOK,
Summary: "annotations and conflict analysis loaded",
}
if m.secondaryLoading {
enrichment.Level = healthUnknown
enrichment.Summary = "annotations and conflict analysis are still loading"
}
if m.details.ConflictFileError != "" {
enrichment.Level = healthWarning
enrichment.Summary = "conflict-file analysis is partial"
enrichment.Detail = m.details.ConflictFileError
}
components = append(components, enrichment)
}
if m.err != nil {
components[0].Level = healthError
components[0].Summary = m.err.Error()
}
if provider, ok := m.service.(healthProvider); ok {
components = append(components, provider.HealthReport()...)
rate := provider.RateLimit()
if !rate.UpdatedAt.IsZero() {
level := healthOK
if rate.Remaining == 0 || time.Now().Before(rate.RetryAfter) {
level = healthError
} else if rate.Limit > 0 && rate.Remaining*10 < rate.Limit {
level = healthWarning
}
summary := fmt.Sprintf("%d/%d points remaining", rate.Remaining, rate.Limit)
detail := ""
if !rate.ResetAt.IsZero() {
detail = "resets " + rate.ResetAt.Local().Format("15:04:05")
}
if time.Now().Before(rate.RetryAfter) {
detail = "retry after " + rate.RetryAfter.Local().Format("15:04:05")
}
components = append(components, HealthComponent{
Name: "rate limit", Level: level, Summary: summary, Detail: detail,
UpdatedAt: rate.UpdatedAt,
})
}
}
sort.SliceStable(components, func(i, j int) bool {
return components[i].Name < components[j].Name
})
for _, component := range components {
style := okStyle
if component.Level == healthWarning {
style = warnStyle
} else if component.Level == healthError {
style = badStyle
} else if component.Level == healthInfo || component.Level == healthUnknown {
style = dimStyle
}
text := healthComponentText(component)
wrapped := ansi.Hardwrap(ansi.Wordwrap(text, width, ""), width, false)
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, style.Render(line))
}
}
lines = append(lines, "", titleStyle.Render("Warnings and errors"))
if len(m.healthEvents) == 0 {
lines = append(lines, dimStyle.Render("No warnings or errors recorded this session."))
}
for index := len(m.healthEvents) - 1; index >= 0; index-- {
event := m.healthEvents[index]
text := fmt.Sprintf(
"%s %-7s %s: %s",
event.At.Local().Format("15:04:05"),
healthLevelLabel(event.Level),
event.Component,
event.Message,
)
wrapped := ansi.Hardwrap(ansi.Wordwrap(text, width, ""), width, false)
style := warnStyle
if event.Level == healthError {
style = badStyle
}
for _, line := range strings.Split(wrapped, "\n") {
lines = append(lines, style.Render(line))
}
}
return lines
}
func (m App) dashboardLines() []string {
if m.writeMode == writePREdit {
return m.dashboardEditLines()
@@ -1758,6 +2213,12 @@ func (m App) dashboardLines() []string {
if pr.FromCache {
lines = append(lines, warnStyle.Render("OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")))
}
if len(pr.DataIssues) > 0 {
lines = append(lines, warnStyle.Render(fmt.Sprintf(
"PARTIAL DATA • %d subsection(s) unavailable; press %s for details",
len(pr.DataIssues), primaryKeyLabel(m.keybindings.Views.Health),
)))
}
if m.loading && pr.BaseRef == "" {
return append(lines, "", "Loading pull request details…")
}
@@ -2744,8 +3205,14 @@ func (m App) frame(lines []string, help string) string {
if m.err != nil {
status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8)))
}
if m.loading {
// Keep an already-rendered screen byte-for-byte stable while a background
// refresh starts. Changing only this footer on a full-height alternate
// screen makes some terminals clear and repaint the entire frame.
initialLoad := m.lastRefresh.IsZero()
if status == "" && m.loading && initialLoad {
status = warnStyle.Render("refreshing…")
} else if status == "" && m.secondaryLoading && initialLoad {
status = warnStyle.Render("loading details…")
}
if status == "" && !m.lastRefresh.IsZero() {
status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05"))