Fix high prio recommendations, add error / health screen
This commit is contained in:
511
tui.go
511
tui.go
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user