4455 lines
133 KiB
Go
4455 lines
133 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/charmbracelet/x/ansi"
|
||
)
|
||
|
||
type screen int
|
||
|
||
const (
|
||
prScreen screen = iota
|
||
dashboardScreen
|
||
threadScreen
|
||
healthScreen
|
||
)
|
||
|
||
type pane int
|
||
|
||
const (
|
||
threadListPane pane = iota
|
||
threadDetailPane
|
||
)
|
||
|
||
type tickMsg time.Time
|
||
type pathTickMsg time.Time
|
||
|
||
type writeMode int
|
||
|
||
const (
|
||
writeNone writeMode = iota
|
||
writeReply
|
||
writeReplyConfirm
|
||
writeReplyBusy
|
||
writeResolveConfirm
|
||
writeResolveBusy
|
||
writePREdit
|
||
writePREditConfirm
|
||
writePREditBusy
|
||
writeAutoMergeConfirm
|
||
writeAutoMergeBusy
|
||
writeMergeNowConfirm
|
||
writeMergeNowBusy
|
||
)
|
||
|
||
type threadResolvedMsg struct {
|
||
threadID string
|
||
thread ReviewThread
|
||
err error
|
||
}
|
||
|
||
type threadRepliedMsg struct {
|
||
threadID string
|
||
comment ReviewComment
|
||
err error
|
||
}
|
||
|
||
type pullRequestUpdatedMsg struct {
|
||
metadata PullRequestMetadata
|
||
people PullRequestPeople
|
||
peopleSaved bool
|
||
err error
|
||
}
|
||
|
||
type autoMergeUpdatedMsg struct {
|
||
request *AutoMergeRequest
|
||
enabled bool
|
||
err error
|
||
}
|
||
|
||
type pullRequestMergedMsg struct {
|
||
result PullRequestMergeResult
|
||
err error
|
||
}
|
||
|
||
type prsLoadedMsg struct {
|
||
prs []PullRequest
|
||
err error
|
||
cached bool
|
||
requestID uint64
|
||
}
|
||
type detailsLoadedMsg struct {
|
||
owner string
|
||
repo string
|
||
number int
|
||
details PRDetails
|
||
err error
|
||
cached bool
|
||
requestID uint64
|
||
}
|
||
|
||
type branchesLoadedMsg struct {
|
||
owner string
|
||
repo string
|
||
branches []RepositoryBranch
|
||
err error
|
||
}
|
||
|
||
type repositoryUsersLoadedMsg struct {
|
||
owner, repo string
|
||
users []RepositoryUser
|
||
err error
|
||
}
|
||
|
||
type detailsEnrichedMsg struct {
|
||
enrichment PRDetailsEnrichment
|
||
requestID uint64
|
||
}
|
||
|
||
type App struct {
|
||
service GitHubService
|
||
owner, repo string
|
||
showAll bool
|
||
limit int
|
||
poll time.Duration
|
||
|
||
screen screen
|
||
healthReturn screen
|
||
healthScroll int
|
||
healthEvents []HealthEvent
|
||
requests *requestCoordinator
|
||
prs []PullRequest
|
||
prIndex int
|
||
details PRDetails
|
||
threadIndex int
|
||
folded map[string]bool
|
||
focus pane
|
||
listHidden bool
|
||
scroll int
|
||
width, height int
|
||
contentTop int
|
||
headerWidth int
|
||
loading bool
|
||
secondaryLoading bool
|
||
err error
|
||
lastRefresh time.Time
|
||
pendingZ bool
|
||
searching bool
|
||
searchQuery string
|
||
searchOrigin int
|
||
helpVisible bool
|
||
helpScroll int
|
||
writeMode writeMode
|
||
writeThreadID string
|
||
replyDraft string
|
||
resolveTarget bool
|
||
autoMergeTarget bool
|
||
mergeMethod string
|
||
prEditField int
|
||
prEditEditors [prEditFieldCount]textEditor
|
||
prEditOriginal PullRequestMetadata
|
||
prEditBranches []RepositoryBranch
|
||
prEditBranchesLoading bool
|
||
prEditBranchesError string
|
||
prEditBranchIndex int
|
||
prEditUsers []RepositoryUser
|
||
prEditUsersLoading bool
|
||
prEditUsersError string
|
||
prEditUserIndex int
|
||
cursorOutput *terminalCursorOutput
|
||
|
||
foldResolved bool
|
||
threadListWidthPercent int
|
||
pathScroll bool
|
||
pathScrollInterval time.Duration
|
||
pathScrollStep int
|
||
threadStatusOrder []string
|
||
threadWithinStatus string
|
||
dashboardMode string
|
||
dashboardReturn screen
|
||
compactReviews bool
|
||
viewerLabel string
|
||
editorMode string
|
||
keybindings KeyBindings
|
||
readState *readStateStore
|
||
drafts *draftStore
|
||
knownThreads map[string]bool
|
||
knownComments map[string]bool
|
||
initializedPRs map[string]bool
|
||
unreadThreads map[string]bool
|
||
unreadComments map[string]bool
|
||
newThreads map[string]bool
|
||
updatedThreads map[string]bool
|
||
ai *AIController
|
||
aiStore *AIStore
|
||
aiMode aiMode
|
||
aiMenuIndex int
|
||
aiInput string
|
||
aiPreview AIPreview
|
||
aiCancel context.CancelFunc
|
||
aiStatus AIProviderStatus
|
||
aiStatusBusy bool
|
||
aiPreviewScroll int
|
||
aiProgress AIRunProgress
|
||
aiSpinner int
|
||
aiEvents <-chan tea.Msg
|
||
difflet diffletModel
|
||
}
|
||
|
||
type AppSettings struct {
|
||
FoldResolved bool
|
||
ThreadListWidthPercent int
|
||
PathScroll bool
|
||
PathScrollInterval time.Duration
|
||
ThreadStatusOrder []string
|
||
ThreadWithinStatus string
|
||
DashboardMode string
|
||
CompactReviews bool
|
||
ViewerLabel string
|
||
EditorMode string
|
||
KeyBindings KeyBindings
|
||
ReadState *readStateStore
|
||
Drafts *draftStore
|
||
AI *AIController
|
||
AIStore *AIStore
|
||
Mascot bool
|
||
MascotExpressive bool
|
||
MascotAnimated bool
|
||
}
|
||
|
||
func defaultAppSettings() AppSettings {
|
||
return AppSettings{
|
||
FoldResolved: true,
|
||
ThreadListWidthPercent: 33,
|
||
PathScrollInterval: 350 * time.Millisecond,
|
||
ThreadStatusOrder: []string{"unresolved", "outdated", "resolved"},
|
||
ThreadWithinStatus: "file",
|
||
DashboardMode: "hotkey",
|
||
CompactReviews: true,
|
||
ViewerLabel: "login",
|
||
EditorMode: "vim",
|
||
KeyBindings: defaultKeyBindings(),
|
||
}
|
||
}
|
||
|
||
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
|
||
return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings())
|
||
}
|
||
|
||
func NewAppWithSettings(
|
||
service GitHubService,
|
||
owner, repo string,
|
||
showAll bool,
|
||
limit int,
|
||
poll time.Duration,
|
||
settings AppSettings,
|
||
) App {
|
||
state := settings.ReadState
|
||
if state == nil {
|
||
state = &readStateStore{Data: make(map[string]readPRState)}
|
||
}
|
||
difflet := newDifflet(settings.Mascot, settings.MascotExpressive, settings.MascotAnimated)
|
||
difflet.visible = false
|
||
return App{
|
||
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
|
||
folded: make(map[string]bool), loading: true,
|
||
foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent,
|
||
pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval,
|
||
threadStatusOrder: append([]string(nil), settings.ThreadStatusOrder...),
|
||
threadWithinStatus: settings.ThreadWithinStatus,
|
||
dashboardMode: settings.DashboardMode, dashboardReturn: prScreen,
|
||
healthReturn: prScreen,
|
||
requests: &requestCoordinator{},
|
||
compactReviews: settings.CompactReviews,
|
||
viewerLabel: firstNonEmpty(settings.ViewerLabel, "login"),
|
||
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),
|
||
unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
|
||
updatedThreads: make(map[string]bool),
|
||
ai: settings.AI, aiStore: settings.AIStore,
|
||
difflet: difflet,
|
||
}
|
||
}
|
||
|
||
func (m App) Init() tea.Cmd {
|
||
commands := []tea.Cmd{m.loadPRs(true), m.nextTick()}
|
||
if m.pathScroll {
|
||
commands = append(commands, m.nextPathTick())
|
||
}
|
||
if command := m.difflet.start(); command != nil {
|
||
commands = append(commands, command)
|
||
}
|
||
return tea.Batch(commands...)
|
||
}
|
||
|
||
func (m App) nextTick() tea.Cmd {
|
||
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 {
|
||
return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) })
|
||
}
|
||
|
||
func (m App) loadPRs(useCache bool) tea.Cmd {
|
||
commands := []tea.Cmd{m.loadLivePRs()}
|
||
if cache, ok := m.service.(cachedSnapshotService); ok && useCache {
|
||
commands = append([]tea.Cmd{func() tea.Msg {
|
||
prs, err := cache.CachedPullRequests(m.owner, m.repo, m.limit, m.showAll)
|
||
return prsLoadedMsg{prs: prs, err: err, cached: true}
|
||
}}, commands...)
|
||
}
|
||
return tea.Batch(commands...)
|
||
}
|
||
|
||
func (m App) loadLivePRs() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx, cancel, requestID := m.requests.start(20 * time.Second)
|
||
defer cancel()
|
||
var prs []PullRequest
|
||
var err error
|
||
if live, ok := m.service.(liveGitHubService); ok {
|
||
prs, err = live.LivePullRequests(ctx, m.owner, m.repo, m.limit, m.showAll)
|
||
} else {
|
||
prs, err = m.service.ListPullRequests(ctx, m.owner, m.repo, m.limit, m.showAll)
|
||
}
|
||
return prsLoadedMsg{prs: prs, err: err, requestID: requestID}
|
||
}
|
||
}
|
||
|
||
func (m App) loadDetails(pr PullRequest, useCache bool) tea.Cmd {
|
||
commands := []tea.Cmd{m.loadLiveDetails(pr)}
|
||
if cache, ok := m.service.(cachedSnapshotService); ok && useCache {
|
||
commands = append([]tea.Cmd{func() tea.Msg {
|
||
details, err := cache.CachedPullRequest(pr.Owner, pr.Repository, pr.Number)
|
||
return detailsLoadedMsg{
|
||
owner: pr.Owner, repo: pr.Repository, number: pr.Number,
|
||
details: details, err: err, cached: true,
|
||
}
|
||
}}, commands...)
|
||
}
|
||
return tea.Batch(commands...)
|
||
}
|
||
|
||
func (m App) loadLiveDetails(pr PullRequest) tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx, cancel, requestID := m.requests.start(60 * time.Second)
|
||
defer cancel()
|
||
var details PRDetails
|
||
var err error
|
||
if live, ok := m.service.(liveGitHubService); ok {
|
||
details, err = live.LivePullRequest(ctx, pr.Owner, pr.Repository, pr.Number)
|
||
} else {
|
||
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, 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *App) startReply() {
|
||
thread := m.selectedThread()
|
||
if thread != nil && thread.Origin == reviewOriginLocalAI {
|
||
m.startAIDiscussion(thread.ID)
|
||
return
|
||
}
|
||
if reason := m.writeActionUnavailable("reply", thread); reason != "" {
|
||
m.err = errors.New(reason)
|
||
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()
|
||
}
|
||
|
||
func (m *App) startResolveToggle() {
|
||
thread := m.selectedThread()
|
||
if reason := m.writeActionUnavailable("resolve", thread); reason != "" {
|
||
m.err = errors.New(reason)
|
||
return
|
||
}
|
||
m.writeMode, m.writeThreadID, m.resolveTarget, m.err =
|
||
writeResolveConfirm, thread.ID, !thread.IsResolved, nil
|
||
}
|
||
|
||
func (m *App) startAutoMergeToggle() {
|
||
target := m.details.AutoMerge == nil
|
||
if reason := m.mergeActionUnavailable(target); reason != "" {
|
||
m.err = errors.New(reason)
|
||
return
|
||
}
|
||
m.autoMergeTarget = target
|
||
m.mergeMethod = preferredMergeMethod(m.details)
|
||
m.writeMode, m.err = writeAutoMergeConfirm, nil
|
||
}
|
||
|
||
func (m *App) startMergeNow() {
|
||
if reason := m.mergeNowUnavailable(); reason != "" {
|
||
m.err = errors.New(reason)
|
||
return
|
||
}
|
||
m.mergeMethod = preferredMergeMethod(m.details)
|
||
m.writeMode, m.err = writeMergeNowConfirm, nil
|
||
}
|
||
|
||
func preferredMergeMethod(pr PRDetails) string {
|
||
for _, preferred := range []string{"SQUASH", "MERGE", "REBASE"} {
|
||
for _, allowed := range pr.AllowedMergeMethods {
|
||
if allowed == preferred {
|
||
return preferred
|
||
}
|
||
}
|
||
}
|
||
return "MERGE"
|
||
}
|
||
|
||
func (m App) mergeActionUnavailable(enabling bool) string {
|
||
if m.loading {
|
||
return "auto-merge unavailable while PR data is refreshing"
|
||
}
|
||
if m.details.FromCache {
|
||
return "auto-merge unavailable from an offline cached snapshot"
|
||
}
|
||
if _, ok := m.service.(GitHubMergeService); !ok {
|
||
return "configured GitHub service does not support auto-merge"
|
||
}
|
||
if m.details.Merged || m.details.State == "CLOSED" {
|
||
return "pull request is already closed"
|
||
}
|
||
if enabling {
|
||
if m.details.HeadOID == "" {
|
||
return "current pull request head commit is unavailable"
|
||
}
|
||
if !m.details.Permissions.CanEnableMerge {
|
||
return "GitHub did not grant permission to enable auto-merge"
|
||
}
|
||
if len(m.details.AllowedMergeMethods) == 0 {
|
||
return "repository does not expose an allowed merge method"
|
||
}
|
||
} else if !m.details.Permissions.CanDisableMerge {
|
||
return "GitHub did not grant permission to disable auto-merge"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m App) mergeNowUnavailable() string {
|
||
if reason := m.mergeActionUnavailable(true); reason != "" &&
|
||
!strings.Contains(reason, "enable auto-merge") {
|
||
return strings.ReplaceAll(reason, "auto-merge", "merge")
|
||
}
|
||
return mergeNowStateReason(m.details)
|
||
}
|
||
|
||
func mergeNowStateReason(pr PRDetails) string {
|
||
if pr.Merged {
|
||
return "pull request is already merged"
|
||
}
|
||
if pr.State == "CLOSED" {
|
||
return "pull request is closed"
|
||
}
|
||
if pr.IsDraft {
|
||
return "draft pull requests cannot be merged"
|
||
}
|
||
if pr.Requirements.RequiresMergeQueue {
|
||
return "this branch requires the merge queue"
|
||
}
|
||
if pr.Mergeable != "MERGEABLE" {
|
||
if pr.Mergeable == "CONFLICTING" {
|
||
return "pull request has merge conflicts"
|
||
}
|
||
return "GitHub has not determined that the pull request is mergeable"
|
||
}
|
||
if pr.Requirements.RequiresApprovals && pr.ReviewDecision != "APPROVED" {
|
||
return "required approving reviews are not complete"
|
||
}
|
||
if pr.Requirements.RequiresStatusChecks &&
|
||
pr.CheckState != "SUCCESS" && pr.CheckState != "EXPECTED" {
|
||
return "required status checks are not successful"
|
||
}
|
||
open, outdated, _ := threadStatusCounts(pr.Threads)
|
||
if pr.Requirements.RequiresConversation && open+outdated > 0 {
|
||
return "required review conversations are unresolved"
|
||
}
|
||
if len(pr.AllowedMergeMethods) == 0 {
|
||
return "repository does not expose an allowed merge method"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
|
||
if thread == nil {
|
||
return "select a review thread first"
|
||
}
|
||
if thread.Origin == reviewOriginLocalAI {
|
||
if m.aiStore == nil {
|
||
return "local AI state is unavailable"
|
||
}
|
||
if action == "reply" && (m.ai == nil || !m.ai.config.Enabled) {
|
||
return "AI discussion is unavailable because AI integration is disabled"
|
||
}
|
||
return ""
|
||
}
|
||
if m.loading {
|
||
return "write action unavailable while PR data is refreshing"
|
||
}
|
||
if m.details.FromCache {
|
||
return "write action unavailable from an offline cached snapshot"
|
||
}
|
||
if _, ok := m.service.(GitHubWriteService); !ok {
|
||
return "configured GitHub service does not support write actions"
|
||
}
|
||
switch action {
|
||
case "reply":
|
||
if !thread.ViewerCanReply {
|
||
return "GitHub did not grant reply permission for this thread"
|
||
}
|
||
case "resolve":
|
||
if thread.IsResolved && !thread.ViewerCanUnresolve {
|
||
return "GitHub did not grant unresolve permission for this thread"
|
||
}
|
||
if !thread.IsResolved && !thread.ViewerCanResolve {
|
||
return "GitHub did not grant resolve permission for this thread"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
if key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
|
||
keyMatches(key.String(), m.keybindings.General.Quit) {
|
||
return m, tea.Quit
|
||
}
|
||
k := m.keybindings.canonicalWriteKey(key.String())
|
||
switch m.writeMode {
|
||
case writeReply:
|
||
switch k {
|
||
case "esc":
|
||
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", ""
|
||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||
case "ctrl+s":
|
||
if strings.TrimSpace(m.replyDraft) == "" {
|
||
m.err = errors.New("reply cannot be empty")
|
||
} else {
|
||
m.writeMode, m.err = writeReplyConfirm, nil
|
||
}
|
||
case "enter":
|
||
m.replyDraft += "\n"
|
||
case "backspace":
|
||
runes := []rune(m.replyDraft)
|
||
if len(runes) > 0 {
|
||
m.replyDraft = string(runes[:len(runes)-1])
|
||
}
|
||
m.err = nil
|
||
default:
|
||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
||
m.replyDraft += textInputKeyValue(key)
|
||
m.err = nil
|
||
}
|
||
}
|
||
if m.writeMode == writeReply {
|
||
m.scroll = m.detailMaxScroll()
|
||
return m, m.queueReplyDraft()
|
||
}
|
||
case writeReplyConfirm:
|
||
switch k {
|
||
case "y":
|
||
m.writeMode = writeReplyBusy
|
||
return m, tea.Batch(m.submitReply(), m.difflet.setState(diffletLoading))
|
||
case "n", "esc":
|
||
m.writeMode = writeReply
|
||
m.scroll = m.detailMaxScroll()
|
||
}
|
||
case writeResolveConfirm:
|
||
switch k {
|
||
case "y":
|
||
m.writeMode = writeResolveBusy
|
||
return m, tea.Batch(m.submitResolution(), m.difflet.setState(diffletLoading))
|
||
case "n", "esc":
|
||
m.writeMode, m.writeThreadID = writeNone, ""
|
||
}
|
||
case writeAutoMergeConfirm:
|
||
switch k {
|
||
case "y":
|
||
m.writeMode = writeAutoMergeBusy
|
||
return m, tea.Batch(m.submitAutoMerge(), m.difflet.setState(diffletLoading))
|
||
case "n", "esc":
|
||
m.writeMode = writeNone
|
||
}
|
||
case writeMergeNowConfirm:
|
||
switch k {
|
||
case "y":
|
||
m.writeMode = writeMergeNowBusy
|
||
return m, tea.Batch(m.submitMergeNow(), m.difflet.setState(diffletLoading))
|
||
case "n", "esc":
|
||
m.writeMode = writeNone
|
||
}
|
||
case writePREdit, writePREditConfirm:
|
||
return m.updatePREditInput(key)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m App) submitReply() tea.Cmd {
|
||
writer := m.service.(GitHubWriteService)
|
||
threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n")
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
comment, err := writer.ReplyToThread(ctx, threadID, body)
|
||
return threadRepliedMsg{threadID: threadID, comment: comment, err: err}
|
||
}
|
||
}
|
||
|
||
func (m App) submitResolution() tea.Cmd {
|
||
if thread := m.threadByID(m.writeThreadID); thread != nil &&
|
||
thread.Origin == reviewOriginLocalAI {
|
||
store, details := m.aiStore, m.details
|
||
threadID, resolved := m.writeThreadID, m.resolveTarget
|
||
return func() tea.Msg {
|
||
thread, err := store.SetResolved(details, threadID, resolved)
|
||
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
|
||
}
|
||
}
|
||
writer := m.service.(GitHubWriteService)
|
||
threadID, resolved := m.writeThreadID, m.resolveTarget
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
thread, err := writer.SetThreadResolved(ctx, threadID, resolved)
|
||
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
|
||
}
|
||
}
|
||
|
||
func (m App) submitAutoMerge() tea.Cmd {
|
||
writer := m.service.(GitHubMergeService)
|
||
id, head, method, enabled := m.details.ID, m.details.HeadOID, m.mergeMethod, m.autoMergeTarget
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
request, err := writer.SetPullRequestAutoMerge(ctx, id, head, method, enabled)
|
||
return autoMergeUpdatedMsg{request: request, enabled: enabled, err: err}
|
||
}
|
||
}
|
||
|
||
func (m App) submitMergeNow() tea.Cmd {
|
||
writer := m.service.(GitHubMergeService)
|
||
id, head, method := m.details.ID, m.details.HeadOID, m.mergeMethod
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
result, err := writer.MergePullRequest(ctx, id, head, method)
|
||
return pullRequestMergedMsg{result: result, err: err}
|
||
}
|
||
}
|
||
|
||
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
if tick, ok := msg.(diffletTickMsg); ok {
|
||
return m, m.difflet.update(tick)
|
||
}
|
||
if m.aiMode != aiNone {
|
||
if updated, command, handled := m.updateAI(msg); handled {
|
||
return updated, command
|
||
}
|
||
}
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
return m, m.difflet.setVisible(
|
||
m.width >= diffletWidth &&
|
||
m.height >= diffletHeight+4,
|
||
)
|
||
case tickMsg:
|
||
if !m.loading {
|
||
m.loading = true
|
||
diffletCommand := m.difflet.setState(diffletLoading)
|
||
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(), diffletCommand)
|
||
}
|
||
return m, tea.Batch(m.loadPRs(false), m.nextTick(), diffletCommand)
|
||
}
|
||
return m, m.nextTick()
|
||
case pathTickMsg:
|
||
if m.pathScroll && m.screen == threadScreen {
|
||
m.pathScrollStep++
|
||
}
|
||
if m.pathScroll {
|
||
return m, m.nextPathTick()
|
||
}
|
||
return m, nil
|
||
case prsLoadedMsg:
|
||
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 {
|
||
m.loading = false
|
||
}
|
||
if msg.err != nil {
|
||
if msg.cached {
|
||
m.recordHealth("pull request cache", healthWarning, msg.err.Error())
|
||
return m, nil
|
||
}
|
||
m.err = msg.err
|
||
command := m.difflet.setState(diffletSad)
|
||
m.recordHealth("pull request list", healthError, msg.err.Error())
|
||
return m, command
|
||
}
|
||
selected := ""
|
||
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
|
||
selected = m.prs[m.prIndex].ID
|
||
}
|
||
m.prs = msg.prs
|
||
sort.SliceStable(m.prs, func(i, j int) bool {
|
||
left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner)
|
||
if left != right {
|
||
return left < right
|
||
}
|
||
return m.prs[i].UpdatedAt.After(m.prs[j].UpdatedAt)
|
||
})
|
||
m.prIndex = indexPR(m.prs, selected)
|
||
m.err = nil
|
||
if msg.cached && len(msg.prs) > 0 {
|
||
m.lastRefresh = msg.prs[0].CachedAt
|
||
} else {
|
||
m.lastRefresh = time.Now()
|
||
}
|
||
if len(m.prs) == 0 {
|
||
return m, m.difflet.setState(diffletSleeping)
|
||
}
|
||
return m, m.difflet.setState(diffletIdle)
|
||
case detailsLoadedMsg:
|
||
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 {
|
||
return m, nil
|
||
}
|
||
if !msg.cached {
|
||
m.loading = false
|
||
}
|
||
if m.details.Number != 0 && (msg.number != m.details.Number ||
|
||
msg.owner != m.details.Owner || msg.repo != m.details.Repository) {
|
||
return m, nil
|
||
}
|
||
if msg.err != nil {
|
||
if msg.cached {
|
||
m.recordHealth("PR cache", healthWarning, msg.err.Error())
|
||
return m, nil
|
||
}
|
||
m.err = msg.err
|
||
command := m.difflet.setState(diffletSad)
|
||
m.recordHealth("PR refresh", healthError, msg.err.Error())
|
||
return m, command
|
||
}
|
||
selected := ""
|
||
anchor := ""
|
||
if m.threadIndex < len(m.details.Threads) {
|
||
selected = m.details.Threads[m.threadIndex].ID
|
||
anchor = m.detailScrollAnchor()
|
||
}
|
||
msg.details = preservePartialPRData(msg.details, withoutLocalAI(m.details))
|
||
if m.aiStore != nil {
|
||
if state, loadErr := m.aiStore.Load(msg.details); loadErr != nil {
|
||
m.recordHealth("local AI state", healthWarning, loadErr.Error())
|
||
} else {
|
||
msg.details = state.Merge(msg.details)
|
||
}
|
||
}
|
||
m.trackThreadUpdates(msg.details)
|
||
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
|
||
m.details = msg.details
|
||
m.err = nil
|
||
diffletCommand := m.difflet.setState(m.restingDiffletState())
|
||
if len(m.updatedThreads) > 0 {
|
||
diffletCommand = m.difflet.setState(diffletNewComment)
|
||
}
|
||
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
|
||
}
|
||
for _, thread := range m.details.Threads {
|
||
if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved {
|
||
m.folded[thread.ID] = true
|
||
}
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
m.scroll = min(m.scroll, m.dashboardMaxScroll())
|
||
} else {
|
||
if anchor != "" {
|
||
m.restoreDetailAnchor(anchor)
|
||
} else {
|
||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||
}
|
||
}
|
||
m.err = nil
|
||
if msg.cached {
|
||
m.lastRefresh = msg.details.CachedAt
|
||
} else {
|
||
m.lastRefresh = time.Now()
|
||
if _, ok := m.service.(GitHubEnrichmentService); ok {
|
||
m.secondaryLoading = true
|
||
diffletCommand = m.difflet.setState(diffletLoading)
|
||
return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand)
|
||
}
|
||
}
|
||
return m, diffletCommand
|
||
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)
|
||
}
|
||
return m, m.difflet.setState(m.restingDiffletState())
|
||
case draftFlushMsg:
|
||
if msg.err != nil {
|
||
m.recordHealth("draft persistence", healthWarning, msg.err.Error())
|
||
}
|
||
case branchesLoadedMsg:
|
||
if m.writeMode != writePREdit ||
|
||
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
|
||
return m, nil
|
||
}
|
||
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
|
||
}
|
||
m.prEditBranches = msg.branches
|
||
m.prEditBranchesError = ""
|
||
m.prEditBranchIndex = 0
|
||
m.ensurePREditCursorVisible()
|
||
case repositoryUsersLoadedMsg:
|
||
if m.writeMode != writePREdit ||
|
||
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
|
||
return m, nil
|
||
}
|
||
m.prEditUsersLoading = false
|
||
if msg.err != nil {
|
||
m.prEditUsersError = msg.err.Error()
|
||
m.recordHealth("repository user recommendations", healthWarning, msg.err.Error())
|
||
m.ensurePREditCursorVisible()
|
||
return m, nil
|
||
}
|
||
m.prEditUsers = msg.users
|
||
m.prEditUsersError = ""
|
||
m.prEditUserIndex = 0
|
||
m.ensurePREditCursorVisible()
|
||
case threadResolvedMsg:
|
||
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, m.difflet.setState(diffletRecoverableError)
|
||
}
|
||
selected := ""
|
||
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
|
||
selected = m.details.Threads[m.threadIndex].ID
|
||
}
|
||
for index := range m.details.Threads {
|
||
if m.details.Threads[index].ID != msg.threadID {
|
||
continue
|
||
}
|
||
thread := &m.details.Threads[index]
|
||
thread.IsResolved = msg.thread.IsResolved
|
||
thread.IsOutdated = msg.thread.IsOutdated
|
||
thread.ViewerCanResolve = msg.thread.ViewerCanResolve
|
||
thread.ViewerCanUnresolve = msg.thread.ViewerCanUnresolve
|
||
thread.ViewerCanReply = msg.thread.ViewerCanReply
|
||
m.folded[thread.ID] = thread.IsResolved && m.foldResolved
|
||
break
|
||
}
|
||
sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
|
||
m.threadIndex = indexThread(m.details.Threads, selected)
|
||
m.writeThreadID = ""
|
||
m.err = nil
|
||
m.lastRefresh = time.Now()
|
||
return m, m.difflet.setState(diffletSuccess)
|
||
case threadRepliedMsg:
|
||
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, m.difflet.setState(diffletRecoverableError)
|
||
}
|
||
m.writeMode = writeNone
|
||
for index := range m.details.Threads {
|
||
if m.details.Threads[index].ID == msg.threadID {
|
||
m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment)
|
||
m.threadIndex = index
|
||
m.markCommentRead(msg.comment.ID)
|
||
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()
|
||
return m, m.difflet.setState(diffletSuccess)
|
||
case autoMergeUpdatedMsg:
|
||
m.writeMode = writeNone
|
||
if msg.err != nil {
|
||
m.err = fmt.Errorf("change auto-merge: %w", msg.err)
|
||
m.recordHealth("auto-merge", healthError, msg.err.Error())
|
||
return m, m.difflet.setState(diffletRecoverableError)
|
||
}
|
||
m.err = nil
|
||
if msg.enabled {
|
||
m.details.AutoMerge = msg.request
|
||
m.details.Permissions.CanEnableMerge = false
|
||
m.details.Permissions.CanDisableMerge = true
|
||
} else {
|
||
m.details.AutoMerge = nil
|
||
m.details.Permissions.CanEnableMerge = true
|
||
m.details.Permissions.CanDisableMerge = false
|
||
}
|
||
m.lastRefresh = time.Now()
|
||
return m, m.difflet.setState(diffletSuccess)
|
||
case pullRequestMergedMsg:
|
||
m.writeMode = writeNone
|
||
if msg.err != nil {
|
||
m.err = fmt.Errorf("merge pull request: %w", msg.err)
|
||
m.recordHealth("merge pull request", healthError, msg.err.Error())
|
||
return m, m.difflet.setState(diffletRecoverableError)
|
||
}
|
||
m.err = nil
|
||
m.details.Merged = msg.result.Merged
|
||
m.details.MergedAt = msg.result.MergedAt
|
||
m.details.State = "MERGED"
|
||
m.details.AutoMerge = nil
|
||
m.lastRefresh = time.Now()
|
||
return m, m.difflet.setState(diffletSuccess)
|
||
case pullRequestUpdatedMsg:
|
||
if msg.err != nil {
|
||
m.writeMode = writePREdit
|
||
if msg.peopleSaved {
|
||
m.applyPREditPeople(msg.people)
|
||
m.prEditOriginal.Reviewers = slices.Clone(msg.people.Reviewers)
|
||
m.prEditOriginal.Assignees = slices.Clone(msg.people.Assignees)
|
||
}
|
||
m.err = fmt.Errorf("update pull request: %w", msg.err)
|
||
m.recordHealth("PR metadata update", healthError, msg.err.Error())
|
||
m.scroll = 0
|
||
return m, m.difflet.setState(diffletRecoverableError)
|
||
}
|
||
m.writeMode = writeNone
|
||
m.details.Title = msg.metadata.Title
|
||
m.details.Body = msg.metadata.Body
|
||
m.details.BaseRef = msg.metadata.BaseRef
|
||
m.applyPREditPeople(PullRequestPeople{
|
||
Reviewers: msg.metadata.Reviewers,
|
||
Assignees: msg.metadata.Assignees,
|
||
})
|
||
m.details.Mergeable = msg.metadata.Mergeable
|
||
m.details.MergeState = msg.metadata.MergeState
|
||
m.details.UpdatedAt = msg.metadata.UpdatedAt
|
||
m.details.ConflictFiles = nil
|
||
m.details.ConflictFileError = ""
|
||
for index := range m.prs {
|
||
if m.prs[index].ID == m.details.ID {
|
||
m.prs[index].Title = msg.metadata.Title
|
||
m.prs[index].UpdatedAt = msg.metadata.UpdatedAt
|
||
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()
|
||
m.loading = true
|
||
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), m.difflet.setState(diffletLoading))
|
||
}
|
||
|
||
key, ok := msg.(tea.KeyMsg)
|
||
if !ok {
|
||
return m, nil
|
||
}
|
||
k := key.String()
|
||
if m.helpVisible {
|
||
k = m.keybindings.canonicalHelpKey(k)
|
||
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.writeMode == writePREdit || m.writeMode == writePREditConfirm {
|
||
editor := m.prEditEditors[m.prEditField]
|
||
helpOutsideTextInput := key.Type != tea.KeyRunes && key.Type != tea.KeySpace
|
||
helpInVimNormalMode := editor.Modal && editor.Mode == textEditorNormal
|
||
if keyMatches(k, m.keybindings.General.Help) &&
|
||
(helpOutsideTextInput || helpInVimNormalMode) {
|
||
m.helpVisible, m.helpScroll = true, 0
|
||
return m, nil
|
||
}
|
||
}
|
||
if m.writeMode != writeNone {
|
||
return m.updateWriteInput(key)
|
||
}
|
||
if m.searching {
|
||
if key.Type != tea.KeyRunes && key.Type != tea.KeySpace &&
|
||
keyMatches(k, m.keybindings.General.Quit) {
|
||
return m, tea.Quit
|
||
}
|
||
k = m.keybindings.canonicalSearchKey(k)
|
||
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 = 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 += textInputKeyValue(key)
|
||
m.selectBestSearchMatch()
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
rawKey := k
|
||
k = m.keybindings.canonicalMainKey(k, m.screen)
|
||
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 keyMatches(rawKey, m.keybindings.Threads.FoldToggle) &&
|
||
m.screen == threadScreen && len(m.details.Threads) > 0 {
|
||
thread := m.details.Threads[m.threadIndex]
|
||
m.folded[thread.ID] = !m.folded[thread.ID]
|
||
m.scroll = 0
|
||
}
|
||
return m, nil
|
||
}
|
||
if k == "z" && m.screen == threadScreen {
|
||
m.pendingZ = true
|
||
return m, nil
|
||
}
|
||
switch k {
|
||
case "A":
|
||
m.openAIMenu()
|
||
case "c":
|
||
if m.screen == threadScreen {
|
||
m.startReply()
|
||
}
|
||
case "R":
|
||
if m.screen == threadScreen {
|
||
m.startResolveToggle()
|
||
}
|
||
case "e":
|
||
if m.screen == dashboardScreen {
|
||
return m, m.startPREdit()
|
||
}
|
||
case "a":
|
||
if m.screen == dashboardScreen {
|
||
m.startAutoMergeToggle()
|
||
}
|
||
case "M":
|
||
if m.screen == dashboardScreen {
|
||
m.startMergeNow()
|
||
}
|
||
case "F":
|
||
if m.screen == threadScreen {
|
||
m.searchQuery = ""
|
||
m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1)
|
||
}
|
||
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
|
||
diffletCommand := m.difflet.setState(diffletLoading)
|
||
targetScreen := m.screen
|
||
if targetScreen == healthScreen {
|
||
targetScreen = m.healthReturn
|
||
}
|
||
if targetScreen == dashboardScreen || targetScreen == threadScreen {
|
||
return m, tea.Batch(m.loadDetails(m.details.PullRequest, false), diffletCommand)
|
||
}
|
||
return m, tea.Batch(m.loadPRs(false), diffletCommand)
|
||
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 {
|
||
m.move(1)
|
||
}
|
||
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 {
|
||
m.move(-1)
|
||
}
|
||
case "g":
|
||
m.toStart()
|
||
case "G":
|
||
m.toEnd()
|
||
case "tab":
|
||
if m.screen == threadScreen {
|
||
m.listHidden = !m.listHidden
|
||
if m.listHidden {
|
||
m.focus = threadDetailPane
|
||
} else {
|
||
m.focus = threadListPane
|
||
}
|
||
m.scroll = 0
|
||
}
|
||
case "ctrl+d", "pgdown":
|
||
m.page(1)
|
||
case "ctrl+u", "pgup":
|
||
m.page(-1)
|
||
case "n":
|
||
if m.screen == threadScreen {
|
||
m.moveToUnread(1)
|
||
}
|
||
case "N":
|
||
if m.screen == threadScreen {
|
||
m.moveToUnread(-1)
|
||
}
|
||
case "m":
|
||
if m.screen == threadScreen {
|
||
m.markCurrentThreadRead()
|
||
}
|
||
case "d":
|
||
if m.screen == prScreen && len(m.prs) > 0 {
|
||
return m, m.openSelectedPR(dashboardScreen)
|
||
}
|
||
if m.screen == threadScreen {
|
||
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())
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false
|
||
return m, nil
|
||
}
|
||
if m.screen == threadScreen {
|
||
m.focus = threadDetailPane
|
||
}
|
||
case "h":
|
||
if m.screen == threadScreen {
|
||
m.focus = threadListPane
|
||
m.listHidden = false
|
||
}
|
||
case "enter":
|
||
if m.screen == prScreen && len(m.prs) > 0 {
|
||
return m, m.openSelectedPR(m.defaultPRTargetScreen())
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
m.screen, m.scroll, m.focus, m.listHidden = threadScreen, 0, threadListPane, false
|
||
return m, nil
|
||
}
|
||
if m.screen == threadScreen && len(m.details.Threads) > 0 {
|
||
thread := m.details.Threads[m.threadIndex]
|
||
m.folded[thread.ID] = !m.folded[thread.ID]
|
||
m.scroll = 0
|
||
}
|
||
case "b", "esc":
|
||
if m.screen == 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
|
||
return m, nil
|
||
}
|
||
m.screen, m.err, m.loading = prScreen, nil, true
|
||
return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
m.screen, m.scroll, m.err = m.dashboardReturn, 0, nil
|
||
if m.screen == prScreen {
|
||
m.loading = true
|
||
m.searching, m.searchQuery = false, ""
|
||
return m, tea.Batch(m.loadPRs(false), m.difflet.setState(diffletLoading))
|
||
}
|
||
return m, nil
|
||
}
|
||
}
|
||
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
|
||
}
|
||
return dashboardScreen
|
||
}
|
||
|
||
func (m *App) openSelectedPR(target screen) tea.Cmd {
|
||
m.screen, m.dashboardReturn = target, prScreen
|
||
m.details = PRDetails{PullRequest: m.prs[m.prIndex]}
|
||
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
|
||
m.searching, m.searchQuery = false, ""
|
||
m.pathScrollStep = 0
|
||
return tea.Batch(m.loadDetails(m.details.PullRequest, true), m.difflet.setState(diffletLoading))
|
||
}
|
||
|
||
func (m *App) trackThreadUpdates(details PRDetails) {
|
||
if m.knownThreads == nil {
|
||
m.knownThreads = make(map[string]bool)
|
||
m.knownComments = make(map[string]bool)
|
||
m.initializedPRs = make(map[string]bool)
|
||
m.unreadThreads = make(map[string]bool)
|
||
m.unreadComments = make(map[string]bool)
|
||
m.newThreads = make(map[string]bool)
|
||
}
|
||
m.updatedThreads = make(map[string]bool)
|
||
prID := details.ID
|
||
if prID == "" {
|
||
prID = fmt.Sprintf("%s#%d", details.RepoWithOwner, details.Number)
|
||
}
|
||
state := m.readState.Data[prID]
|
||
if state.Threads == nil {
|
||
state.Threads = make(map[string]bool)
|
||
state.Comments = make(map[string]bool)
|
||
}
|
||
if !state.Initialized {
|
||
for _, thread := range details.Threads {
|
||
state.Threads[thread.ID] = true
|
||
for _, comment := range thread.Comments {
|
||
state.Comments[comment.ID] = true
|
||
}
|
||
}
|
||
state.Initialized = true
|
||
m.readState.Data[prID] = state
|
||
if err := m.readState.save(); err != nil {
|
||
m.recordHealth("read state", healthWarning, err.Error())
|
||
}
|
||
return
|
||
}
|
||
for _, thread := range details.Threads {
|
||
threadIsNew := !state.Threads[thread.ID]
|
||
updated := threadIsNew
|
||
if threadIsNew {
|
||
m.newThreads[thread.ID] = true
|
||
}
|
||
for _, comment := range thread.Comments {
|
||
if !state.Comments[comment.ID] {
|
||
updated = true
|
||
m.unreadComments[comment.ID] = true
|
||
}
|
||
m.knownComments[comment.ID] = true
|
||
}
|
||
m.knownThreads[thread.ID] = true
|
||
if updated {
|
||
m.unreadThreads[thread.ID] = true
|
||
m.updatedThreads[thread.ID] = true
|
||
}
|
||
}
|
||
m.initializedPRs[prID] = true
|
||
}
|
||
|
||
func (m *App) markCurrentThreadRead() {
|
||
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
|
||
thread := m.details.Threads[m.threadIndex]
|
||
delete(m.unreadThreads, thread.ID)
|
||
delete(m.newThreads, thread.ID)
|
||
prID := m.currentPRKey()
|
||
state := m.readState.Data[prID]
|
||
if state.Threads == nil {
|
||
state.Threads = make(map[string]bool)
|
||
}
|
||
if state.Comments == nil {
|
||
state.Comments = make(map[string]bool)
|
||
}
|
||
state.Initialized = true
|
||
state.Threads[thread.ID] = true
|
||
for _, comment := range thread.Comments {
|
||
state.Comments[comment.ID] = true
|
||
delete(m.unreadComments, comment.ID)
|
||
}
|
||
m.readState.Data[prID] = state
|
||
if err := m.readState.save(); err != nil {
|
||
m.recordHealth("read state", healthWarning, err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *App) markCommentRead(commentID string) {
|
||
if commentID == "" {
|
||
return
|
||
}
|
||
delete(m.unreadComments, commentID)
|
||
prID := m.currentPRKey()
|
||
state := m.readState.Data[prID]
|
||
if state.Comments == nil {
|
||
state.Comments = make(map[string]bool)
|
||
}
|
||
state.Comments[commentID] = true
|
||
m.readState.Data[prID] = state
|
||
if err := m.readState.save(); err != nil {
|
||
m.recordHealth("read state", healthWarning, err.Error())
|
||
}
|
||
}
|
||
|
||
func (m App) currentPRKey() string {
|
||
if m.details.ID != "" {
|
||
return m.details.ID
|
||
}
|
||
return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number)
|
||
}
|
||
|
||
func (m *App) scrollToFirstUnread() {
|
||
if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
|
||
return
|
||
}
|
||
thread := m.details.Threads[m.threadIndex]
|
||
firstUnread := ""
|
||
for _, comment := range thread.Comments {
|
||
if m.unreadComments[comment.ID] {
|
||
firstUnread = comment.ID
|
||
break
|
||
}
|
||
}
|
||
if firstUnread == "" {
|
||
return
|
||
}
|
||
width, _ := m.detailPaneSize()
|
||
dividerAnchor := "unread:" + firstUnread
|
||
commentAnchor := "comment:" + firstUnread + ":header"
|
||
for index, line := range m.renderedDetailLines(width) {
|
||
if line.anchor == dividerAnchor || line.anchor == commentAnchor {
|
||
m.scroll = min(index, m.detailMaxScroll())
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *App) acknowledgeVisibleUnread() {
|
||
if m.screen != threadScreen || m.focus != threadDetailPane ||
|
||
m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
|
||
return
|
||
}
|
||
thread := m.details.Threads[m.threadIndex]
|
||
lastUnread := ""
|
||
for _, comment := range thread.Comments {
|
||
if m.unreadComments[comment.ID] {
|
||
lastUnread = comment.ID
|
||
}
|
||
}
|
||
if lastUnread == "" {
|
||
return
|
||
}
|
||
width, _ := m.detailPaneSize()
|
||
lines := m.renderedDetailLines(width)
|
||
viewportHeight := m.detailViewportHeight()
|
||
scroll := min(m.scroll, max(0, len(lines)-viewportHeight))
|
||
lastAnchor := "comment:" + lastUnread + ":header"
|
||
for index, line := range lines {
|
||
if line.anchor == lastAnchor && index >= scroll && index < scroll+viewportHeight {
|
||
m.markCurrentThreadRead()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *App) moveToUnread(direction int) {
|
||
count := len(m.details.Threads)
|
||
if count == 0 {
|
||
return
|
||
}
|
||
for offset := 1; offset <= count; offset++ {
|
||
index := (m.threadIndex + direction*offset) % count
|
||
if index < 0 {
|
||
index += count
|
||
}
|
||
if m.unreadThreads[m.details.Threads[index].ID] {
|
||
m.threadIndex, m.scroll = index, 0
|
||
m.focus = threadDetailPane
|
||
m.scrollToFirstUnread()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *App) move(delta int) {
|
||
if m.screen == prScreen {
|
||
m.prIndex = clamp(m.prIndex+delta, 0, len(m.prs)-1)
|
||
return
|
||
}
|
||
if m.searchQuery != "" {
|
||
matches := m.matchingThreadIndices()
|
||
if len(matches) == 0 {
|
||
return
|
||
}
|
||
position := slices.Index(matches, m.threadIndex)
|
||
if position < 0 {
|
||
position = 0
|
||
}
|
||
m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)]
|
||
m.scroll = 0
|
||
return
|
||
}
|
||
m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1)
|
||
m.scroll = 0
|
||
}
|
||
|
||
func (m *App) moveSearch(delta int) {
|
||
matches := m.matchingThreadIndices()
|
||
if len(matches) == 0 {
|
||
return
|
||
}
|
||
position := 0
|
||
for i, index := range matches {
|
||
if index == m.threadIndex {
|
||
position = i
|
||
break
|
||
}
|
||
}
|
||
position = clamp(position+delta, 0, len(matches)-1)
|
||
m.threadIndex = matches[position]
|
||
m.scroll = 0
|
||
}
|
||
|
||
func (m *App) selectBestSearchMatch() {
|
||
matches := m.matchingThreadIndices()
|
||
if len(matches) > 0 {
|
||
m.threadIndex = matches[0]
|
||
m.scroll = 0
|
||
}
|
||
}
|
||
|
||
func (m App) matchingThreadIndices() []int {
|
||
if m.searchQuery == "" {
|
||
indices := make([]int, len(m.details.Threads))
|
||
for i := range m.details.Threads {
|
||
indices[i] = i
|
||
}
|
||
return indices
|
||
}
|
||
type match struct {
|
||
index int
|
||
score int
|
||
}
|
||
filter := parseThreadFilter(m.searchQuery)
|
||
matches := make([]match, 0, len(m.details.Threads))
|
||
for i, thread := range m.details.Threads {
|
||
if filter.status != "" && threadStatus(thread) != filter.status {
|
||
continue
|
||
}
|
||
if filter.updated && !m.updatedThreads[thread.ID] {
|
||
continue
|
||
}
|
||
if filter.author != "" && !m.threadHasAuthor(thread, filter.author) {
|
||
continue
|
||
}
|
||
if score, ok := fuzzyPathScore(thread.Path, filter.path); 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
|
||
}
|
||
|
||
type threadFilter struct {
|
||
path string
|
||
status string
|
||
author string
|
||
updated bool
|
||
}
|
||
|
||
func parseThreadFilter(query string) threadFilter {
|
||
var filter threadFilter
|
||
var pathTerms []string
|
||
for _, term := range strings.Fields(query) {
|
||
key, value, found := strings.Cut(term, ":")
|
||
if !found {
|
||
pathTerms = append(pathTerms, term)
|
||
continue
|
||
}
|
||
switch strings.ToLower(key) {
|
||
case "status":
|
||
value = strings.ToLower(value)
|
||
if value == "open" {
|
||
value = "unresolved"
|
||
}
|
||
if value == "unresolved" || value == "outdated" || value == "resolved" {
|
||
filter.status = value
|
||
}
|
||
case "author":
|
||
filter.author = strings.TrimPrefix(strings.ToLower(value), "@")
|
||
case "updated", "new":
|
||
filter.updated = value == "" || value == "1" || strings.EqualFold(value, "true") ||
|
||
strings.EqualFold(value, "yes")
|
||
default:
|
||
pathTerms = append(pathTerms, term)
|
||
}
|
||
}
|
||
filter.path = strings.Join(pathTerms, " ")
|
||
return filter
|
||
}
|
||
|
||
func (m App) threadHasAuthor(thread ReviewThread, author string) bool {
|
||
for _, comment := range thread.Comments {
|
||
if strings.Contains(strings.ToLower(comment.Author), author) ||
|
||
strings.Contains(strings.ToLower(m.displayAuthor(comment.Author)), author) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func sortReviewThreads(threads []ReviewThread, statusOrder []string, withinStatus string) {
|
||
ranks := map[string]int{
|
||
"unresolved": 0,
|
||
"outdated": 1,
|
||
"resolved": 2,
|
||
}
|
||
for rank, status := range statusOrder {
|
||
ranks[status] = rank
|
||
}
|
||
if withinStatus == "" {
|
||
withinStatus = "file"
|
||
}
|
||
sort.SliceStable(threads, func(i, j int) bool {
|
||
left, right := threads[i], threads[j]
|
||
leftRank, rightRank := ranks[threadStatus(left)], ranks[threadStatus(right)]
|
||
if leftRank != rightRank {
|
||
return leftRank < rightRank
|
||
}
|
||
if withinStatus == "timestamp" {
|
||
if less, decided := compareThreadTimestamps(left, right); decided {
|
||
return less
|
||
}
|
||
}
|
||
leftPath, rightPath := strings.ToLower(left.Path), strings.ToLower(right.Path)
|
||
if leftPath != rightPath {
|
||
return leftPath < rightPath
|
||
}
|
||
if left.Line != right.Line {
|
||
return left.Line < right.Line
|
||
}
|
||
if withinStatus != "timestamp" {
|
||
if less, decided := compareThreadTimestamps(left, right); decided {
|
||
return less
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
|
||
func threadStatus(thread ReviewThread) string {
|
||
if thread.IsResolved {
|
||
return "resolved"
|
||
}
|
||
if thread.IsOutdated {
|
||
return "outdated"
|
||
}
|
||
return "unresolved"
|
||
}
|
||
|
||
func threadOpenedAt(thread ReviewThread) time.Time {
|
||
var opened time.Time
|
||
for _, comment := range thread.Comments {
|
||
if comment.CreatedAt.IsZero() || (!opened.IsZero() && !comment.CreatedAt.Before(opened)) {
|
||
continue
|
||
}
|
||
opened = comment.CreatedAt
|
||
}
|
||
return opened
|
||
}
|
||
|
||
func compareThreadTimestamps(left, right ReviewThread) (less, decided bool) {
|
||
leftTime, rightTime := threadOpenedAt(left), threadOpenedAt(right)
|
||
switch {
|
||
case leftTime.IsZero() && rightTime.IsZero():
|
||
return false, false
|
||
case leftTime.IsZero():
|
||
return false, true
|
||
case rightTime.IsZero():
|
||
return true, true
|
||
case !leftTime.Equal(rightTime):
|
||
return leftTime.Before(rightTime), true
|
||
default:
|
||
return false, false
|
||
}
|
||
}
|
||
|
||
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 {
|
||
m.scroll = 0
|
||
m.acknowledgeVisibleUnread()
|
||
} else {
|
||
m.threadIndex, m.scroll = 0, 0
|
||
}
|
||
}
|
||
func (m *App) toEnd() {
|
||
if m.screen == prScreen {
|
||
m.prIndex = max(0, len(m.prs)-1)
|
||
} else if m.screen == dashboardScreen {
|
||
m.scroll = m.dashboardMaxScroll()
|
||
} else if m.screen == healthScreen {
|
||
m.healthScroll = m.healthMaxScroll()
|
||
} else if m.focus == threadDetailPane {
|
||
m.scroll = m.detailMaxScroll()
|
||
m.acknowledgeVisibleUnread()
|
||
} else {
|
||
m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0
|
||
}
|
||
}
|
||
func (m *App) page(direction int) {
|
||
if m.screen == dashboardScreen {
|
||
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
|
||
}
|
||
m.move(direction * max(3, m.height/2))
|
||
}
|
||
|
||
func (m *App) scrollDetail(delta int) {
|
||
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
|
||
m.acknowledgeVisibleUnread()
|
||
}
|
||
|
||
func (m *App) scrollDashboard(delta int) {
|
||
maxScroll := m.dashboardMaxScroll()
|
||
m.scroll = clamp(m.scroll+delta, 0, maxScroll)
|
||
}
|
||
|
||
func (m App) dashboardViewportHeight() int {
|
||
return max(1, m.height-2)
|
||
}
|
||
|
||
func (m App) dashboardMaxScroll() int {
|
||
return max(0, len(m.dashboardDisplayLines())-m.dashboardViewportHeight())
|
||
}
|
||
|
||
func (m App) detailPaneSize() (int, int) {
|
||
topLines := m.threadTopLineCount()
|
||
height := max(3, m.height-topLines-1)
|
||
if m.width < 70 || m.listHidden {
|
||
return max(3, m.width), height
|
||
}
|
||
leftWidth := m.threadListWidth()
|
||
return max(20, m.width-leftWidth-1), height
|
||
}
|
||
|
||
func (m App) threadTopLineCount() int {
|
||
topLines := len(m.threadTopLines())
|
||
if m.headerWidth > 0 {
|
||
topLines++
|
||
}
|
||
return topLines
|
||
}
|
||
|
||
func (m App) threadListWidth() int {
|
||
percent := m.threadListWidthPercent
|
||
if percent == 0 {
|
||
percent = 33
|
||
}
|
||
return clamp(m.width*percent/100, 30, max(30, m.width-20))
|
||
}
|
||
|
||
func (m App) detailViewportHeight() int {
|
||
_, height := m.detailPaneSize()
|
||
return max(1, height-2)
|
||
}
|
||
|
||
func (m App) detailMaxScroll() int {
|
||
width, _ := m.detailPaneSize()
|
||
return max(0, len(m.renderedDetailLines(width))-m.detailViewportHeight())
|
||
}
|
||
|
||
func (m App) View() string {
|
||
if m.cursorOutput != nil {
|
||
m.cursorOutput.SetCursor(false, 0, 0)
|
||
}
|
||
if m.width == 0 {
|
||
return "Loading…"
|
||
}
|
||
mascot := m.difflet.frameLines()
|
||
if len(mascot) != diffletHeight {
|
||
return m.viewContent()
|
||
}
|
||
if m.diffletHiddenForCurrentView() {
|
||
return m.viewContent()
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
|
||
}
|
||
gap := diffletGap
|
||
headerWidth := diffletHeaderWidth(m.width)
|
||
if headerWidth < 1 {
|
||
headerWidth = m.width
|
||
}
|
||
content := m
|
||
content.headerWidth = headerWidth
|
||
headerLineCount, hasHeader := content.diffletHeaderLineCount()
|
||
if !hasHeader {
|
||
content.height = max(1, m.height-diffletHeight-1)
|
||
content.contentTop = diffletHeight + 1
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||
strings.Join(mascot, "\n") + "\n\n" + content.viewContent(),
|
||
)
|
||
}
|
||
bandHeight := max(diffletHeight, headerLineCount)
|
||
addedRows := bandHeight - headerLineCount
|
||
if m.width <= diffletWidth {
|
||
addedRows = diffletHeight
|
||
}
|
||
content.height = max(1, m.height-addedRows)
|
||
content.contentTop = addedRows
|
||
rendered := content.viewContent()
|
||
header, body, ok := splitHeader(rendered)
|
||
if !ok {
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||
strings.Join(mascot, "\n") + "\n\n" + rendered,
|
||
)
|
||
}
|
||
headerBand := renderHeaderWithDifflet(header, mascot, m.width, headerWidth, gap)
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
|
||
headerBand + "\n\n" + body,
|
||
)
|
||
}
|
||
|
||
func (m App) diffletHiddenForCurrentView() bool {
|
||
return m.helpVisible ||
|
||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
|
||
(m.writeMode != writeNone && m.writeMode != writeReply) ||
|
||
m.screen == healthScreen
|
||
}
|
||
|
||
func (m App) diffletHeaderLineCount() (int, bool) {
|
||
if m.diffletHiddenForCurrentView() {
|
||
return 0, false
|
||
}
|
||
switch m.screen {
|
||
case prScreen:
|
||
return len(m.prHeaderLines()), true
|
||
case dashboardScreen:
|
||
if m.scroll > 0 {
|
||
return 0, false
|
||
}
|
||
return len(m.dashboardHeaderLines()), true
|
||
case threadScreen:
|
||
return len(m.threadTopLines()), true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func splitHeader(view string) (header []string, body string, ok bool) {
|
||
lines := strings.Split(view, "\n")
|
||
for index, line := range lines {
|
||
if strings.TrimSpace(ansi.Strip(line)) == "" {
|
||
if index == 0 {
|
||
return nil, view, false
|
||
}
|
||
return lines[:index], strings.Join(lines[index+1:], "\n"), true
|
||
}
|
||
}
|
||
return nil, view, false
|
||
}
|
||
|
||
func renderHeaderWithDifflet(
|
||
header, mascot []string,
|
||
width, headerWidth, gap int,
|
||
) string {
|
||
if width <= diffletWidth {
|
||
lines := append([]string(nil), mascot...)
|
||
lines = append(lines, header...)
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
height := max(len(header), len(mascot))
|
||
headerText := strings.Join(header, "\n")
|
||
left := lipgloss.NewStyle().Width(headerWidth).Height(height).Render(headerText)
|
||
right := lipgloss.NewStyle().Width(diffletWidth).Height(height).Render(strings.Join(mascot, "\n"))
|
||
return lipgloss.JoinHorizontal(
|
||
lipgloss.Top,
|
||
left,
|
||
strings.Repeat(" ", gap),
|
||
right,
|
||
)
|
||
}
|
||
|
||
func (m App) wrapHeaderLines(lines []string) []string {
|
||
if m.headerWidth < 1 {
|
||
return lines
|
||
}
|
||
var wrapped []string
|
||
for _, line := range lines {
|
||
value := ansi.Hardwrap(ansi.Wordwrap(line, m.headerWidth, ""), m.headerWidth, false)
|
||
wrapped = append(wrapped, strings.Split(value, "\n")...)
|
||
}
|
||
return wrapped
|
||
}
|
||
|
||
func (m App) viewContent() string {
|
||
if m.helpVisible {
|
||
return m.viewHelp()
|
||
}
|
||
if m.aiMode != aiNone && m.aiMode != aiDiscussion {
|
||
return m.viewAI()
|
||
}
|
||
if m.writeMode != writeNone && m.writeMode != writeReply {
|
||
if m.writeMode == writePREdit {
|
||
return m.viewDashboard()
|
||
}
|
||
return m.viewWritePopup()
|
||
}
|
||
if m.screen == prScreen {
|
||
return m.viewPRs()
|
||
}
|
||
if m.screen == dashboardScreen {
|
||
return m.viewDashboard()
|
||
}
|
||
if m.screen == healthScreen {
|
||
return m.viewHealth()
|
||
}
|
||
return m.viewThreads()
|
||
}
|
||
|
||
func (m App) viewWritePopup() string {
|
||
width := max(20, min(76, m.width-4))
|
||
thread := m.threadByID(m.writeThreadID)
|
||
location := "selected thread"
|
||
if thread != nil {
|
||
location = fmt.Sprintf("%s:%d", thread.Path, thread.Line)
|
||
}
|
||
var lines []string
|
||
switch m.writeMode {
|
||
case writeReply:
|
||
lines = []string{
|
||
titleStyle.Render("Reply to " + location),
|
||
"",
|
||
}
|
||
lines = append(lines, renderTextInput(
|
||
m.replyDraft, width-2, m.cursorOutput != nil,
|
||
)...)
|
||
if m.err != nil {
|
||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||
}
|
||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
||
"%s newline • %s review • %s cancel",
|
||
primaryKeyLabel(m.keybindings.Input.Newline),
|
||
primaryKeyLabel(m.keybindings.Input.Submit),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)))
|
||
case writeReplyConfirm:
|
||
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
|
||
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
|
||
lines = append(lines, "", warnStyle.Render(fmt.Sprintf(
|
||
"%s submit • %s continue editing",
|
||
primaryKeyLabel(m.keybindings.General.Confirm),
|
||
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||
)))
|
||
case writeReplyBusy:
|
||
lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)}
|
||
case writeResolveConfirm:
|
||
action := "resolve"
|
||
if !m.resolveTarget {
|
||
action = "unresolve"
|
||
}
|
||
lines = []string{
|
||
titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"),
|
||
"",
|
||
warnStyle.Render(fmt.Sprintf(
|
||
"%s confirm • %s cancel",
|
||
primaryKeyLabel(m.keybindings.General.Confirm),
|
||
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||
)),
|
||
}
|
||
case writeResolveBusy:
|
||
action := "Resolving"
|
||
if !m.resolveTarget {
|
||
action = "Unresolving"
|
||
}
|
||
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
|
||
case writeAutoMergeConfirm:
|
||
action := "Enable"
|
||
detail := " using " + strings.ToLower(m.mergeMethod)
|
||
if !m.autoMergeTarget {
|
||
action, detail = "Disable", ""
|
||
}
|
||
lines = []string{
|
||
titleStyle.Render(fmt.Sprintf("%s auto-merge for %s #%d%s?",
|
||
action, m.details.RepoWithOwner, m.details.Number, detail)),
|
||
"",
|
||
warnStyle.Render(fmt.Sprintf(
|
||
"%s confirm • %s cancel",
|
||
primaryKeyLabel(m.keybindings.General.Confirm),
|
||
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||
)),
|
||
}
|
||
case writeAutoMergeBusy:
|
||
action := "Enabling"
|
||
if !m.autoMergeTarget {
|
||
action = "Disabling"
|
||
}
|
||
lines = []string{titleStyle.Render(action + " auto-merge…")}
|
||
case writeMergeNowConfirm:
|
||
lines = []string{
|
||
titleStyle.Render(fmt.Sprintf(
|
||
"Merge %s #%d into %s now using %s?",
|
||
m.details.RepoWithOwner, m.details.Number, m.details.BaseRef,
|
||
strings.ToLower(m.mergeMethod),
|
||
)),
|
||
"",
|
||
badStyle.Render("This action cannot be undone from diple."),
|
||
"",
|
||
warnStyle.Render(fmt.Sprintf(
|
||
"%s merge now • %s cancel",
|
||
primaryKeyLabel(m.keybindings.General.Confirm),
|
||
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||
)),
|
||
}
|
||
case writeMergeNowBusy:
|
||
lines = []string{titleStyle.Render("Merging pull request…")}
|
||
case writePREditConfirm:
|
||
lines = m.prEditConfirmationLines(width - 2)
|
||
case writePREditBusy:
|
||
lines = []string{titleStyle.Render("Updating pull request…")}
|
||
}
|
||
maxLines := max(3, m.height-4)
|
||
if len(lines) > maxLines {
|
||
if m.writeMode == writePREditConfirm {
|
||
start := clamp(m.helpScroll, 0, len(lines)-maxLines)
|
||
lines = lines[start : start+maxLines]
|
||
} else {
|
||
lines = lines[len(lines)-maxLines:]
|
||
}
|
||
}
|
||
for index := range lines {
|
||
lines[index] = ansi.Truncate(lines[index], width, "")
|
||
}
|
||
popup := paneStyle(true).Width(width).Render(strings.Join(lines, "\n"))
|
||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
|
||
}
|
||
|
||
func (m App) threadByID(id string) *ReviewThread {
|
||
for index := range m.details.Threads {
|
||
if m.details.Threads[index].ID == id {
|
||
thread := m.details.Threads[index]
|
||
return &thread
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type helpBinding struct {
|
||
key string
|
||
action string
|
||
}
|
||
|
||
func (m App) helpBindings() []helpBinding {
|
||
if m.writeMode == writePREdit || m.writeMode == writePREditConfirm {
|
||
bindings := []helpBinding{
|
||
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), "Move to the next / previous field"},
|
||
{keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"},
|
||
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
|
||
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
|
||
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"},
|
||
}
|
||
if m.prEditEditors[prEditBodyField].Modal {
|
||
bindings = append(bindings,
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
||
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
|
||
), "Move left / down / up / right in Normal or Visual mode"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.WordForward, m.keybindings.Vim.WORDForward,
|
||
m.keybindings.Vim.WordBackward, m.keybindings.Vim.WORDBackward,
|
||
m.keybindings.Vim.WordEnd, m.keybindings.Vim.WORDEnd,
|
||
), "Move by words or WORDs"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.LineStart, m.keybindings.Vim.FirstNonBlank,
|
||
m.keybindings.Vim.LineEnd,
|
||
), "Move to the line start, first non-blank, or line end"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last,
|
||
), "Move to the start / end of the description"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.Insert, m.keybindings.Vim.Append,
|
||
m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd,
|
||
m.keybindings.Vim.ReplaceCharacter,
|
||
), "Enter Insert mode"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove,
|
||
), "Open a line below / above"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine,
|
||
), "Start character-wise / line-wise Visual mode"},
|
||
helpBinding{keyLabel(m.keybindings.Vim.SelectionOtherEnd), "Move to the other end of a Visual selection"},
|
||
helpBinding{keyLabel(m.keybindings.Vim.Yank), "Copy the Visual selection to the system clipboard"},
|
||
helpBinding{keyLabel(m.keybindings.Vim.Paste), "Paste from the system clipboard"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.Delete, m.keybindings.Vim.DeleteBefore,
|
||
), "Delete the selection or text at / before the cursor"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.FindForward, m.keybindings.Vim.FindBackward,
|
||
m.keybindings.Vim.TillForward, m.keybindings.Vim.TillBackward,
|
||
), "Find or move until a character on the current visual line"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Vim.RepeatFind, m.keybindings.Vim.RepeatFindReverse,
|
||
), "Repeat the last character find forward / backward"},
|
||
)
|
||
} else {
|
||
bindings = append(bindings,
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
|
||
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
|
||
), "Move the description cursor"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd,
|
||
), "Move to the start / end of the current visual line"},
|
||
helpBinding{keyLabel(m.keybindings.Input.Newline), "Insert a newline"},
|
||
helpBinding{combinedKeyLabel(
|
||
m.keybindings.Input.DeleteBackward, m.keybindings.Input.DeleteForward,
|
||
), "Delete text before / at the cursor"},
|
||
)
|
||
}
|
||
bindings = append(bindings,
|
||
helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"},
|
||
helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"},
|
||
)
|
||
return bindings
|
||
}
|
||
if m.screen == prScreen {
|
||
openAction := "Open pull request dashboard"
|
||
if m.dashboardMode == "hotkey" {
|
||
openAction = "Open review threads"
|
||
}
|
||
return []helpBinding{
|
||
{keyLabel(m.keybindings.Navigation.Down), "Next pull request"},
|
||
{keyLabel(m.keybindings.Navigation.Up), "Previous pull request"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last pull request"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
||
{keyLabel(m.keybindings.Views.Open), openAction},
|
||
{keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
|
||
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
||
{keyLabel(m.keybindings.General.Help), "Close this help"},
|
||
{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 {
|
||
backAction = "Return to review threads"
|
||
}
|
||
return []helpBinding{
|
||
{keyLabel(m.keybindings.Navigation.Down), "Scroll description down"},
|
||
{keyLabel(m.keybindings.Navigation.Up), "Scroll description 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.Views.Edit), "Edit title, branch, reviewers, assignees, and description"},
|
||
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
|
||
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
|
||
{keyLabel(m.keybindings.Views.AI), "Open the local AI review menu"},
|
||
{keyLabel(m.keybindings.Views.Open), "Open review threads"},
|
||
{keyLabel(m.keybindings.General.Back), backAction},
|
||
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
||
{keyLabel(m.keybindings.General.Help), "Close this help"},
|
||
{keyLabel(m.keybindings.General.Quit), "Quit"},
|
||
}
|
||
}
|
||
backAction := "Return to pull requests"
|
||
if m.dashboardMode == "intermediate" {
|
||
backAction = "Return to PR dashboard"
|
||
}
|
||
bindings := []helpBinding{
|
||
{combinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), "Focus thread list / detail"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), "Move or scroll focused pane"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last item"},
|
||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
||
{keyLabel(m.keybindings.Views.ToggleList), "Hide / reveal thread list"},
|
||
{keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
|
||
{keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"},
|
||
{combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
|
||
{keyLabel(m.keybindings.Threads.MarkRead), "Mark the selected thread read"},
|
||
{keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
|
||
{keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
|
||
{keyLabel(m.keybindings.Views.AI), "Open the local AI review or selected-thread discussion menu"},
|
||
{keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"},
|
||
{combinedKeyLabel(
|
||
m.keybindings.Threads.Toggle,
|
||
[]string{sequenceKeyLabel(m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle)},
|
||
), "Fold / expand thread"},
|
||
{keyLabel(m.keybindings.General.Back), backAction},
|
||
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
||
}
|
||
bindings = append(bindings,
|
||
helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"},
|
||
helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"},
|
||
)
|
||
return bindings
|
||
}
|
||
|
||
func (m App) helpVisibleRows() int {
|
||
// Reserve rows for the popup border, title, title divider, and footer.
|
||
return max(1, m.height-6)
|
||
}
|
||
|
||
func (m App) helpMaxScroll() int {
|
||
return max(0, len(m.helpRows(m.helpContentWidth()))-m.helpVisibleRows())
|
||
}
|
||
|
||
func (m App) viewHelp() string {
|
||
contentWidth := m.helpContentWidth()
|
||
rows := m.helpRows(contentWidth)
|
||
visibleRows := m.helpVisibleRows()
|
||
start := clamp(m.helpScroll, 0, max(0, len(rows)-visibleRows))
|
||
end := min(len(rows), start+visibleRows)
|
||
|
||
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 {
|
||
title = "Review thread keys"
|
||
}
|
||
contentLines := append([]string(nil), rows[start:end]...)
|
||
if len(rows) > visibleRows {
|
||
contentLines = append(contentLines, dimStyle.Render(fmt.Sprintf(
|
||
"%d–%d of %d • %s/%s scroll • %s close",
|
||
start+1, end, len(rows),
|
||
primaryKeyLabel(m.keybindings.Navigation.Down),
|
||
primaryKeyLabel(m.keybindings.Navigation.Up),
|
||
primaryKeyLabel(m.keybindings.General.Help)+"/"+
|
||
primaryKeyLabel(m.keybindings.General.Back),
|
||
)))
|
||
} else {
|
||
contentLines = append(contentLines, dimStyle.Render(
|
||
primaryKeyLabel(m.keybindings.General.Help)+"/"+
|
||
primaryKeyLabel(m.keybindings.General.Back)+" close",
|
||
))
|
||
}
|
||
for i := range contentLines {
|
||
contentLines[i] = ansi.Truncate(contentLines[i], contentWidth, "")
|
||
}
|
||
border := lipgloss.NewStyle().Foreground(paneActiveColor)
|
||
boxLines := []string{
|
||
border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"),
|
||
border.Render("│") + pad(titleStyle.Render(title), contentWidth) + border.Render("│"),
|
||
border.Render("├" + strings.Repeat("─", contentWidth) + "┤"),
|
||
}
|
||
for _, line := range contentLines {
|
||
boxLines = append(boxLines,
|
||
border.Render("│")+pad(line, contentWidth)+border.Render("│"),
|
||
)
|
||
}
|
||
boxLines = append(boxLines, 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) helpContentWidth() int {
|
||
return max(1, min(70, m.width-4))
|
||
}
|
||
|
||
func (m App) helpRows(contentWidth int) []string {
|
||
keyWidth := min(17, max(8, contentWidth/3))
|
||
actionWidth := max(1, contentWidth-keyWidth-1)
|
||
var rows []string
|
||
bindings := m.helpBindings()
|
||
for bindingIndex, binding := range bindings {
|
||
wrappedKeys := ansi.Hardwrap(
|
||
ansi.Wordwrap(binding.key, keyWidth, ""),
|
||
keyWidth,
|
||
false,
|
||
)
|
||
keyLines := strings.Split(wrappedKeys, "\n")
|
||
wrappedAction := ansi.Hardwrap(
|
||
ansi.Wordwrap(binding.action, actionWidth, ""),
|
||
actionWidth,
|
||
false,
|
||
)
|
||
actionLines := strings.Split(wrappedAction, "\n")
|
||
lineCount := max(len(keyLines), len(actionLines))
|
||
for lineIndex := range lineCount {
|
||
key, action := "", ""
|
||
if lineIndex < len(keyLines) {
|
||
key = keyLines[lineIndex]
|
||
}
|
||
if lineIndex < len(actionLines) {
|
||
action = actionLines[lineIndex]
|
||
}
|
||
rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action)
|
||
}
|
||
if bindingIndex < len(bindings)-1 {
|
||
rows = append(rows, dimStyle.Render(strings.Repeat("─", contentWidth)))
|
||
}
|
||
}
|
||
return rows
|
||
}
|
||
|
||
var (
|
||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
|
||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
|
||
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
|
||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
|
||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
|
||
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
|
||
editorLineStyle = lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("#D7DAE8")).
|
||
Background(lipgloss.Color("#2C3045"))
|
||
paneInactiveColor = lipgloss.Color("#50566F")
|
||
paneActiveColor = lipgloss.Color("#F0B72F")
|
||
|
||
selectedLineBackground = "\x1b[48;5;24m"
|
||
suggestionRemoveBackground = "\x1b[48;5;52m"
|
||
suggestionAddBackground = "\x1b[48;5;22m"
|
||
changedRemoveBackground = "\x1b[48;2;55;0;0m"
|
||
changedAddBackground = "\x1b[48;2;0;55;0m"
|
||
)
|
||
|
||
func paneStyle(active bool) lipgloss.Style {
|
||
color := paneInactiveColor
|
||
if active {
|
||
color = paneActiveColor
|
||
}
|
||
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color)
|
||
}
|
||
|
||
func (m App) viewPRs() string {
|
||
lines := append(m.prHeaderLines(), "")
|
||
if m.loading && len(m.prs) == 0 {
|
||
lines = append(lines, "Loading open pull requests…")
|
||
} else if len(m.prs) == 0 && m.err == nil {
|
||
lines = append(lines, "No matching assigned pull requests.")
|
||
}
|
||
rows, selectedRow := groupedPRRows(m.prs, m.prIndex)
|
||
available := max(1, m.height-4)
|
||
start := windowStart(selectedRow, len(rows), available)
|
||
for _, row := range rows[start:min(len(rows), start+available)] {
|
||
if row.header {
|
||
lines = append(lines, titleStyle.Render(row.repository))
|
||
continue
|
||
}
|
||
pr := m.prs[row.prIndex]
|
||
draft := ""
|
||
if pr.IsDraft {
|
||
draft = " DRAFT"
|
||
}
|
||
if pr.FromCache {
|
||
draft += " CACHED"
|
||
}
|
||
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)
|
||
}
|
||
footer := fmt.Sprintf(
|
||
"%s keys • %s move • %s dashboard • %s quit",
|
||
primaryKeyLabel(m.keybindings.General.Help),
|
||
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
|
||
primaryKeyLabel(m.keybindings.Views.Open),
|
||
primaryKeyLabel(m.keybindings.General.Quit),
|
||
)
|
||
if m.dashboardMode == "hotkey" {
|
||
footer = fmt.Sprintf(
|
||
"%s keys • %s move • %s threads • %s dashboard • %s quit",
|
||
primaryKeyLabel(m.keybindings.General.Help),
|
||
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
|
||
primaryKeyLabel(m.keybindings.Views.Open),
|
||
primaryKeyLabel(m.keybindings.Views.Dashboard),
|
||
primaryKeyLabel(m.keybindings.General.Quit),
|
||
)
|
||
}
|
||
return m.frame(lines, footer)
|
||
}
|
||
|
||
func (m App) prHeaderLines() []string {
|
||
header := titleStyle.Render("diple")
|
||
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")
|
||
}
|
||
return m.wrapHeaderLines([]string{header})
|
||
}
|
||
|
||
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) viewDashboard() string {
|
||
return m.viewDashboardWithLines(m.dashboardLines())
|
||
}
|
||
|
||
func (m App) viewDashboardWithLines(lines []string) string {
|
||
viewportHeight := m.dashboardViewportHeight()
|
||
maxScroll := max(0, len(lines)-viewportHeight)
|
||
scroll := min(m.scroll, maxScroll)
|
||
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
|
||
footer := fmt.Sprintf(
|
||
"%s keys • %s scroll • %s AI • %s threads • %s back • %s quit",
|
||
primaryKeyLabel(m.keybindings.General.Help),
|
||
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
|
||
primaryKeyLabel(m.keybindings.Views.AI),
|
||
primaryKeyLabel(m.keybindings.Views.Open),
|
||
primaryKeyLabel(m.keybindings.General.Back),
|
||
primaryKeyLabel(m.keybindings.General.Quit),
|
||
)
|
||
if m.writeMode == writePREdit {
|
||
footer = fmt.Sprintf(
|
||
"%s keys • %s fields • %s review • %s normal/cancel",
|
||
primaryKeyLabel(m.keybindings.General.Help),
|
||
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField),
|
||
primaryKeyLabel(m.keybindings.Input.Submit),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)
|
||
m.positionPREditHardwareCursor(scroll, viewportHeight)
|
||
}
|
||
view := m.frame(visible, footer)
|
||
if m.cursorOutput != nil {
|
||
view += m.cursorOutput.FrameMarker()
|
||
}
|
||
return view
|
||
}
|
||
|
||
func (m App) dashboardDisplayLines() []string {
|
||
mascot := m.difflet.frameLines()
|
||
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
|
||
return m.dashboardLinesWithDifflet(mascot)
|
||
}
|
||
return m.dashboardLines()
|
||
}
|
||
|
||
func (m App) dashboardLinesWithDifflet(mascot []string) []string {
|
||
lines := m.dashboardLines()
|
||
headerLines := len(m.dashboardHeaderLines())
|
||
metadataStart := headerLines + 1
|
||
if headerLines >= len(lines) ||
|
||
strings.TrimSpace(ansi.Strip(lines[headerLines])) != "" {
|
||
return lines
|
||
}
|
||
if len(lines) < metadataStart+diffletHeight {
|
||
result := append([]string(nil), lines[:headerLines]...)
|
||
result = append(result, centeredDiffletLines(mascot, m.width)...)
|
||
return append(result, lines[metadataStart:]...)
|
||
}
|
||
band := renderDashboardMetadataWithDifflet(
|
||
lines[metadataStart:metadataStart+diffletHeight],
|
||
mascot,
|
||
m.width,
|
||
)
|
||
result := append([]string(nil), lines[:headerLines]...)
|
||
result = append(result, band...)
|
||
return append(result, lines[metadataStart+diffletHeight:]...)
|
||
}
|
||
|
||
func renderDashboardMetadataWithDifflet(metadata, mascot []string, width int) []string {
|
||
mascotLeft := max(0, (width-diffletWidth)/2)
|
||
if width <= diffletWidth || mascotLeft < diffletGap {
|
||
centered := centeredDiffletLines(mascot, width)
|
||
return append(centered, metadata...)
|
||
}
|
||
metadataWidth := max(1, mascotLeft-diffletGap)
|
||
height := max(len(metadata), len(mascot))
|
||
rendered := make([]string, 0, height)
|
||
for row := range height {
|
||
left, right := "", ""
|
||
if row < len(metadata) {
|
||
left = ansi.Truncate(metadata[row], metadataWidth, "…")
|
||
}
|
||
if row < len(mascot) {
|
||
right = mascot[row]
|
||
}
|
||
rendered = append(rendered, pad(left, mascotLeft)+right)
|
||
}
|
||
return rendered
|
||
}
|
||
|
||
func centeredDiffletLines(mascot []string, width int) []string {
|
||
centered := make([]string, 0, len(mascot))
|
||
for _, line := range mascot {
|
||
centered = append(centered, lipgloss.PlaceHorizontal(
|
||
width, lipgloss.Center, line,
|
||
))
|
||
}
|
||
return centered
|
||
}
|
||
|
||
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",
|
||
Detail: "theme " + currentThemeName,
|
||
})
|
||
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.ai == nil || !m.ai.config.Enabled {
|
||
components = append(components, HealthComponent{
|
||
Name: "AI integration", Level: healthInfo,
|
||
Summary: "disabled by configuration; no PR data is sent to a model",
|
||
})
|
||
} else {
|
||
status := m.aiStatus
|
||
if status.Summary == "" {
|
||
status.Summary = "enabled; provider status not checked yet"
|
||
status.Detail = "open the AI menu to probe Codex authentication and model availability"
|
||
}
|
||
level := healthOK
|
||
if !status.Ready {
|
||
level = healthWarning
|
||
}
|
||
components = append(components, HealthComponent{
|
||
Name: "AI provider", Level: level, Summary: status.Summary,
|
||
Detail: strings.TrimSpace(status.Detail + " model " + status.Model),
|
||
})
|
||
if m.aiStore != nil {
|
||
storeHealth := HealthComponent{
|
||
Name: "local AI state", Level: healthOK,
|
||
Summary: "atomic per-PR storage is available", Detail: m.aiStore.dir,
|
||
}
|
||
if m.aiStore.loadErr != nil {
|
||
storeHealth.Level = healthWarning
|
||
storeHealth.Summary = m.aiStore.loadErr.Error()
|
||
}
|
||
components = append(components, storeHealth)
|
||
}
|
||
}
|
||
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) dashboardHeaderLines() []string {
|
||
pr := m.details
|
||
width := max(10, m.width-2)
|
||
draft := ""
|
||
if pr.IsDraft {
|
||
draft = " " + warnStyle.Render("DRAFT")
|
||
}
|
||
lines := []string{
|
||
titleStyle.Render(fmt.Sprintf("%s #%d", pr.RepoWithOwner, pr.Number)) + draft,
|
||
titleStyle.Render(truncate(pr.Title, width)),
|
||
}
|
||
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),
|
||
)))
|
||
}
|
||
return m.wrapHeaderLines(lines)
|
||
}
|
||
|
||
func (m App) dashboardLines() []string {
|
||
if m.writeMode == writePREdit {
|
||
return m.dashboardEditLines()
|
||
}
|
||
pr := m.details
|
||
width := max(10, m.width-2)
|
||
lines := m.dashboardHeaderLines()
|
||
if m.loading && pr.BaseRef == "" {
|
||
return append(lines, "", "Loading pull request details…")
|
||
}
|
||
|
||
open, outdated, resolved := threadStatusCounts(pr.Threads)
|
||
created := "unknown"
|
||
if !pr.CreatedAt.IsZero() {
|
||
created = pr.CreatedAt.Local().Format("2006-01-02 15:04")
|
||
}
|
||
updated := "unknown"
|
||
if !pr.UpdatedAt.IsZero() {
|
||
updated = pr.UpdatedAt.Local().Format("2006-01-02 15:04")
|
||
}
|
||
milestone := firstNonEmpty(pr.Milestone, "none")
|
||
labels := "none"
|
||
if len(pr.Labels) > 0 {
|
||
labels = strings.Join(pr.Labels, ", ")
|
||
}
|
||
lines = append(lines,
|
||
"",
|
||
dashboardMetadata("author", m.authorText(pr.Author)),
|
||
dashboardMetadata("branches", pr.HeadRef+" → "+pr.BaseRef),
|
||
dashboardMetadata("review", reviewAndMergeState(pr)),
|
||
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
||
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
||
dashboardMetadata("auto-merge", m.autoMergeStateText(pr)),
|
||
)
|
||
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
||
lines = append(lines,
|
||
dashboardMetadata("assignees", m.handlesText(pr.Assignees)),
|
||
dashboardMetadata("reviewers", m.reviewersText(pr.Reviewers)),
|
||
dashboardMetadata("labels", labels),
|
||
dashboardMetadata("milestone", milestone),
|
||
dashboardMetadata("activity", fmt.Sprintf(
|
||
"%d commits • %d conversation comments", pr.CommitCount, pr.CommentCount,
|
||
)),
|
||
dashboardMetadata("changes", fmt.Sprintf(
|
||
"%s %s • %d files",
|
||
okStyle.Render(fmt.Sprintf("+%d", pr.Additions)),
|
||
badStyle.Render(fmt.Sprintf("-%d", pr.Deletions)),
|
||
pr.ChangedFiles,
|
||
)),
|
||
dashboardMetadata("threads", fmt.Sprintf(
|
||
"%d open • %d outdated • %d resolved", open, outdated, resolved,
|
||
)),
|
||
dashboardMetadata("requirements", mergeRequirementsText(pr.Requirements)),
|
||
dashboardMetadata("permission", viewerPermissionsText(pr.Permissions)),
|
||
dashboardMetadata("head sha", shortOID(pr.HeadOID)),
|
||
dashboardMetadata("created", created),
|
||
dashboardMetadata("updated", updated),
|
||
dashboardMetadata("url", pr.URL),
|
||
)
|
||
if len(pr.ConflictFiles) > 0 {
|
||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf(
|
||
"Conflicting files (%d)", len(pr.ConflictFiles),
|
||
)), "")
|
||
for _, file := range pr.ConflictFiles {
|
||
parts := strings.Split(ansi.Hardwrap(file, max(1, width-4), false), "\n")
|
||
for index, part := range parts {
|
||
prefix := " "
|
||
if index == 0 {
|
||
prefix = "• "
|
||
}
|
||
lines = append(lines, badStyle.Render(prefix)+part)
|
||
}
|
||
}
|
||
}
|
||
lines = append(lines, "", titleStyle.Render("Write capability gate"), "")
|
||
lines = append(lines, writeCapabilityLines(pr, m.selectedThread())...)
|
||
if pr.ThreadsTruncated {
|
||
lines = append(lines, warnStyle.Render("Thread totals only include the first 100 review threads."))
|
||
}
|
||
lines = append(lines, "", titleStyle.Render("Description"), "")
|
||
if strings.TrimSpace(pr.Body) == "" {
|
||
lines = append(lines, dimStyle.Render("No description provided."))
|
||
} else {
|
||
lines = append(lines, renderCommentMarkdown(pr.Body, width)...)
|
||
}
|
||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Checks (%d)", len(pr.Checks))), "")
|
||
if len(pr.Checks) == 0 {
|
||
lines = append(lines, dimStyle.Render("No individual checks reported."))
|
||
}
|
||
for _, check := range pr.Checks {
|
||
line := coloredState(check.State) + " " + check.Name
|
||
if check.URL != "" {
|
||
line += " " + dimStyle.Render(check.URL)
|
||
}
|
||
lines = append(lines, line)
|
||
if check.Summary != "" && check.State != "SUCCESS" {
|
||
lines = append(lines, dimStyle.Render(" "+truncate(strings.Join(strings.Fields(check.Summary), " "), width-2)))
|
||
}
|
||
for _, annotation := range check.Annotations {
|
||
location := fmt.Sprintf("%s:%d", annotation.Path, annotation.StartLine)
|
||
lines = append(lines, " "+coloredState(strings.ToUpper(annotation.Level))+" "+
|
||
location+" "+truncate(firstNonEmpty(annotation.Title, annotation.Message), max(10, width-len(location)-8)))
|
||
}
|
||
}
|
||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Commit and force-push timeline (%d)", len(pr.Timeline))), "")
|
||
if len(pr.Timeline) == 0 {
|
||
lines = append(lines, dimStyle.Render("No commit timeline events reported."))
|
||
}
|
||
for _, event := range pr.Timeline {
|
||
when := event.CreatedAt.Local().Format("2006-01-02 15:04")
|
||
if event.Kind == "force-push" {
|
||
lines = append(lines, warnStyle.Render("force-push")+" "+shortOID(event.BeforeOID)+" → "+
|
||
shortOID(event.AfterOID)+" "+m.authorText(event.Author)+" "+dimStyle.Render(when))
|
||
} else {
|
||
lines = append(lines, shortOID(event.OID)+" "+truncate(event.Title, max(10, width-35))+
|
||
" "+m.authorText(event.Author)+" "+dimStyle.Render(when))
|
||
}
|
||
}
|
||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Applicable rulesets (%d)", applicableRulesetCount(pr.Rulesets))), "")
|
||
for _, ruleset := range pr.Rulesets {
|
||
if ruleset.Applies {
|
||
lines = append(lines, ruleset.Name+" "+dimStyle.Render(strings.ToLower(ruleset.Enforcement))+
|
||
" "+strings.Join(ruleset.RuleTypes, ", "))
|
||
}
|
||
}
|
||
if applicableRulesetCount(pr.Rulesets) == 0 {
|
||
lines = append(lines, dimStyle.Render("No applicable active rulesets reported."))
|
||
}
|
||
if pr.MergeQueue != nil {
|
||
lines = append(lines, dashboardMetadata("merge queue", fmt.Sprintf(
|
||
"%s, position %d", strings.ToLower(pr.MergeQueue.State), pr.MergeQueue.Position,
|
||
)))
|
||
}
|
||
lines = append(lines, "", titleStyle.Render(fmt.Sprintf("Submitted reviews (%d)", len(pr.Reviews))), "")
|
||
if len(pr.Reviews) == 0 {
|
||
lines = append(lines, dimStyle.Render("No submitted reviews."))
|
||
}
|
||
if m.compactReviews {
|
||
lines = append(lines, m.compactReviewLines(pr.Reviews, width)...)
|
||
if len(pr.Reviews) > 0 {
|
||
lines = append(lines, "")
|
||
}
|
||
} else {
|
||
for _, review := range pr.Reviews {
|
||
header := m.authorText(review.Author) + " " +
|
||
coloredState(review.State)
|
||
if !review.SubmittedAt.IsZero() {
|
||
header += " " + dimStyle.Render(review.SubmittedAt.Local().Format("2006-01-02 15:04"))
|
||
}
|
||
if review.CommitOID != "" {
|
||
header += " " + dimStyle.Render(shortOID(review.CommitOID))
|
||
}
|
||
lines = append(lines, header)
|
||
if strings.TrimSpace(review.Body) != "" {
|
||
lines = append(lines, renderCommentMarkdown(review.Body, width)...)
|
||
}
|
||
lines = append(lines, "")
|
||
}
|
||
}
|
||
lines = append(lines, titleStyle.Render(fmt.Sprintf("Conversation (%d)", len(pr.Conversation))), "")
|
||
if len(pr.Conversation) == 0 {
|
||
lines = append(lines, dimStyle.Render("No PR conversation comments."))
|
||
}
|
||
for _, comment := range pr.Conversation {
|
||
header := m.authorText(comment.Author)
|
||
if !comment.CreatedAt.IsZero() {
|
||
header += " " + dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04"))
|
||
}
|
||
lines = append(lines, header)
|
||
lines = append(lines, renderCommentMarkdown(comment.Body, width)...)
|
||
lines = append(lines, "")
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m App) compactReviewLines(reviews []ReviewSummary, width int) []string {
|
||
if len(reviews) == 0 {
|
||
return nil
|
||
}
|
||
|
||
stateCounts := make(map[string]int)
|
||
authorCounts := make(map[string]int)
|
||
for _, review := range reviews {
|
||
stateCounts[review.State]++
|
||
authorCounts[review.Author]++
|
||
}
|
||
|
||
states := make([]string, 0, len(stateCounts))
|
||
for state := range stateCounts {
|
||
states = append(states, state)
|
||
}
|
||
sort.Strings(states)
|
||
authors := make([]string, 0, len(authorCounts))
|
||
for author := range authorCounts {
|
||
authors = append(authors, author)
|
||
}
|
||
sort.Slice(authors, func(i, j int) bool {
|
||
if authorCounts[authors[i]] != authorCounts[authors[j]] {
|
||
return authorCounts[authors[i]] > authorCounts[authors[j]]
|
||
}
|
||
return strings.ToLower(authors[i]) < strings.ToLower(authors[j])
|
||
})
|
||
|
||
summary := make([]string, 0, len(states)+len(authors))
|
||
for _, state := range states {
|
||
summary = append(summary, coloredState(state)+dimStyle.Render(fmt.Sprintf(" ×%d", stateCounts[state])))
|
||
}
|
||
for _, author := range authors {
|
||
summary = append(summary,
|
||
m.authorText(author)+dimStyle.Render(fmt.Sprintf(" ×%d", authorCounts[author])),
|
||
)
|
||
}
|
||
lines := []string{ansi.Truncate(strings.Join(summary, dimStyle.Render(" • ")), width, "…")}
|
||
for _, review := range reviews {
|
||
if body := compactReviewBody(review.Body, width); body != "" {
|
||
line := m.authorText(review.Author) + " " +
|
||
coloredState(review.State) + dimStyle.Render(" — ") + body
|
||
lines = append(lines, ansi.Truncate(line, width, "…"))
|
||
}
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func compactReviewBody(body string, width int) string {
|
||
if strings.TrimSpace(body) == "" {
|
||
return ""
|
||
}
|
||
rendered := ansi.Strip(strings.Join(renderCommentMarkdown(body, width), " "))
|
||
return strings.Join(strings.Fields(rendered), " ")
|
||
}
|
||
|
||
func dashboardMetadata(label, value string) string {
|
||
return titleStyle.Render(pad(label+":", 13)) + " " + value
|
||
}
|
||
|
||
func dashboardMetadataLines(label, value string, width int) []string {
|
||
const labelWidth = 13
|
||
valueWidth := max(1, width-labelWidth-1)
|
||
wrapped := ansi.Hardwrap(ansi.Wordwrap(value, valueWidth, ""), valueWidth, false)
|
||
parts := strings.Split(wrapped, "\n")
|
||
lines := make([]string, 0, len(parts))
|
||
for index, part := range parts {
|
||
currentLabel := ""
|
||
if index == 0 {
|
||
currentLabel = label + ":"
|
||
}
|
||
lines = append(lines, titleStyle.Render(pad(currentLabel, labelWidth))+" "+part)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func threadStatusCounts(threads []ReviewThread) (open, outdated, resolved int) {
|
||
for _, thread := range threads {
|
||
switch threadStatus(thread) {
|
||
case "resolved":
|
||
resolved++
|
||
case "outdated":
|
||
outdated++
|
||
default:
|
||
open++
|
||
}
|
||
}
|
||
return open, outdated, resolved
|
||
}
|
||
|
||
func mergeRequirementsText(requirements MergeRequirements) string {
|
||
var items []string
|
||
if requirements.RequiresApprovals {
|
||
approval := "approvals"
|
||
if requirements.ApprovalsRequired == 1 {
|
||
approval = "approval"
|
||
}
|
||
items = append(items, fmt.Sprintf("%d %s", requirements.ApprovalsRequired, approval))
|
||
}
|
||
if requirements.RequiresCodeOwnerReview {
|
||
items = append(items, "code owner review")
|
||
}
|
||
if requirements.RequiresStatusChecks {
|
||
items = append(items, "status checks")
|
||
}
|
||
if requirements.RequiresStrictChecks {
|
||
items = append(items, "up-to-date branch")
|
||
}
|
||
if requirements.RequiresDeployments {
|
||
item := "deployments"
|
||
if len(requirements.RequiredDeployments) > 0 {
|
||
item += " (" + strings.Join(requirements.RequiredDeployments, ", ") + ")"
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if requirements.RequiresLinearHistory {
|
||
items = append(items, "linear history")
|
||
}
|
||
if requirements.RequiresSignatures {
|
||
items = append(items, "signed commits")
|
||
}
|
||
if requirements.RequiresMergeQueue {
|
||
items = append(items, "merge queue")
|
||
}
|
||
if requirements.RequiresConversation {
|
||
items = append(items, "resolved conversations")
|
||
}
|
||
if len(items) == 0 {
|
||
return "none reported"
|
||
}
|
||
return strings.Join(items, ", ")
|
||
}
|
||
|
||
func applicableRulesetCount(rulesets []Ruleset) int {
|
||
count := 0
|
||
for _, ruleset := range rulesets {
|
||
if ruleset.Applies {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
type writeCapability struct {
|
||
name, reason string
|
||
enabled bool
|
||
}
|
||
|
||
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||
if pr.FromCache {
|
||
reason := "offline cached snapshot"
|
||
return []writeCapability{
|
||
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
|
||
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
|
||
{name: "auto-merge", reason: reason}, {name: "merge now", reason: reason},
|
||
}
|
||
}
|
||
threadReason := "select a review thread"
|
||
canReply, canResolve := false, false
|
||
resolveName := "resolve thread"
|
||
if thread != nil {
|
||
canReply = thread.ViewerCanReply
|
||
if thread.IsResolved {
|
||
resolveName = "unresolve thread"
|
||
canResolve = thread.ViewerCanUnresolve
|
||
} else {
|
||
canResolve = thread.ViewerCanResolve
|
||
}
|
||
threadReason = "GitHub did not grant permission for this thread"
|
||
}
|
||
autoMergeAllowed := !pr.Merged && pr.State != "CLOSED" &&
|
||
(pr.Permissions.CanEnableMerge || pr.Permissions.CanDisableMerge)
|
||
autoMergeReason := "auto-merge is unavailable for this PR"
|
||
if pr.Merged || pr.State == "CLOSED" {
|
||
autoMergeReason = "pull request is already closed"
|
||
}
|
||
return []writeCapability{
|
||
capability("reply", canReply, threadReason, true),
|
||
capability(resolveName, canResolve, threadReason, true),
|
||
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
|
||
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
|
||
capability("auto-merge", autoMergeAllowed, autoMergeReason, true),
|
||
capability("merge now", mergeNowStateReason(pr) == "",
|
||
firstNonEmpty(mergeNowStateReason(pr), "available"), true),
|
||
}
|
||
}
|
||
|
||
func capability(name string, allowed bool, denied string, implemented bool) writeCapability {
|
||
if !allowed {
|
||
return writeCapability{name: name, reason: denied}
|
||
}
|
||
if !implemented {
|
||
return writeCapability{name: name, reason: "write action not implemented yet"}
|
||
}
|
||
return writeCapability{name: name, enabled: true, reason: "available"}
|
||
}
|
||
|
||
func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string {
|
||
var lines []string
|
||
for _, item := range writeCapabilities(pr, thread) {
|
||
marker := badStyle.Render("disabled")
|
||
if item.enabled {
|
||
marker = okStyle.Render("ready")
|
||
}
|
||
lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m App) selectedThread() *ReviewThread {
|
||
if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
|
||
return nil
|
||
}
|
||
thread := m.details.Threads[m.threadIndex]
|
||
return &thread
|
||
}
|
||
|
||
func viewerPermissionsText(permissions ViewerPermissions) string {
|
||
items := []string{strings.ToLower(firstNonEmpty(permissions.Repository, "unknown"))}
|
||
if permissions.CanUpdatePR {
|
||
items = append(items, "update")
|
||
}
|
||
if permissions.CanResolveAny {
|
||
items = append(items, "resolve")
|
||
}
|
||
if permissions.CanUnresolveAny {
|
||
items = append(items, "unresolve")
|
||
}
|
||
if permissions.CanReplyAny {
|
||
items = append(items, "reply")
|
||
}
|
||
if permissions.CanEnableMerge {
|
||
items = append(items, "enable auto-merge")
|
||
}
|
||
if permissions.CanDisableMerge {
|
||
items = append(items, "disable auto-merge")
|
||
}
|
||
return strings.Join(items, ", ")
|
||
}
|
||
|
||
func (m App) threadTopLines() []string {
|
||
pr := m.details
|
||
width := m.width
|
||
if m.headerWidth > 0 {
|
||
width = m.headerWidth
|
||
}
|
||
header := titleStyle.Render(fmt.Sprintf("%s #%d %s", pr.RepoWithOwner, pr.Number, truncate(pr.Title, max(10, width-len(pr.RepoWithOwner)-12))))
|
||
meta := fmt.Sprintf("%s → %s checks: %s %s", pr.HeadRef, pr.BaseRef, coloredState(pr.CheckState), reviewAndMergeState(pr))
|
||
people := "assignees: " + m.handlesText(pr.Assignees) + " reviewers: " + m.reviewersText(pr.Reviewers)
|
||
top := []string{header, meta, people}
|
||
if pr.FromCache {
|
||
top = append(top, warnStyle.Render(
|
||
"CACHED snapshot • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04")+
|
||
" • refreshing live data",
|
||
))
|
||
}
|
||
if pr.ThreadsTruncated {
|
||
top = append(top, warnStyle.Render("Showing the first 100 review threads."))
|
||
}
|
||
return m.wrapHeaderLines(top)
|
||
}
|
||
|
||
func (m App) viewThreads() string {
|
||
top := m.threadTopLines()
|
||
topLineCount := len(top)
|
||
if m.headerWidth > 0 {
|
||
topLineCount++
|
||
}
|
||
contentHeight := max(3, m.height-topLineCount-1)
|
||
var body string
|
||
if m.width < 70 {
|
||
if m.focus == threadListPane {
|
||
body = m.threadList(max(3, m.width), contentHeight)
|
||
} else {
|
||
body = m.threadDetail(max(3, m.width), contentHeight)
|
||
}
|
||
} else if m.listHidden {
|
||
body = m.threadDetail(m.width, contentHeight)
|
||
} else {
|
||
leftWidth := m.threadListWidth()
|
||
rightWidth := max(20, m.width-leftWidth-1)
|
||
left := m.threadList(leftWidth, contentHeight)
|
||
right := m.threadDetail(rightWidth, contentHeight)
|
||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
||
}
|
||
help := fmt.Sprintf(
|
||
"%s keys • %s focus • %s move/scroll • %s AI • %s reply • %s resolve • %s dashboard • %s back • %s quit",
|
||
primaryKeyLabel(m.keybindings.General.Help),
|
||
primaryCombinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right),
|
||
primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up),
|
||
primaryKeyLabel(m.keybindings.Views.AI),
|
||
primaryKeyLabel(m.keybindings.Threads.Reply),
|
||
primaryKeyLabel(m.keybindings.Threads.Resolve),
|
||
primaryKeyLabel(m.keybindings.Views.Dashboard),
|
||
primaryKeyLabel(m.keybindings.General.Back),
|
||
primaryKeyLabel(m.keybindings.General.Quit),
|
||
)
|
||
if m.searching {
|
||
help = fmt.Sprintf(
|
||
"path words • status:open • author:name • updated:true • %s apply • %s cancel",
|
||
primaryKeyLabel(m.keybindings.Input.Newline),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)
|
||
} else if m.writeMode == writeReply {
|
||
help = fmt.Sprintf(
|
||
"reply inline • %s newline • %s review • %s cancel",
|
||
primaryKeyLabel(m.keybindings.Input.Newline),
|
||
primaryKeyLabel(m.keybindings.Input.Submit),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)
|
||
} else if m.aiMode == aiDiscussion {
|
||
help = fmt.Sprintf(
|
||
"local AI discussion • %s newline • %s prepare • %s cancel",
|
||
primaryKeyLabel(m.keybindings.Input.Newline),
|
||
primaryKeyLabel(m.keybindings.Input.Submit),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)
|
||
}
|
||
m.positionThreadInputHardwareCursor()
|
||
if m.headerWidth > 0 {
|
||
top = append(top, "")
|
||
}
|
||
view := m.frame(append(top, body), help)
|
||
if m.cursorOutput != nil {
|
||
view += m.cursorOutput.FrameMarker()
|
||
}
|
||
return view
|
||
}
|
||
|
||
func (m App) threadList(width, height int) string {
|
||
innerWidth := max(1, width-2)
|
||
innerHeight := max(1, height-2)
|
||
matches := m.matchingThreadIndices()
|
||
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))}
|
||
if m.searching {
|
||
queryWidth := max(1, innerWidth-len("Filter: ")-1)
|
||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
||
lines = append(lines, titleStyle.Render("Filter: ")+query+
|
||
inputCursorFallback(m.cursorOutput != nil))
|
||
}
|
||
if m.loading && len(m.details.Threads) == 0 {
|
||
lines = append(lines, "Loading…")
|
||
}
|
||
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 len(matches) == 0 && m.searchQuery != "" {
|
||
lines = append(lines, dimStyle.Render("No matching threads."))
|
||
}
|
||
for _, i := range matches[start:min(len(matches), start+available)] {
|
||
thread := m.details.Threads[i]
|
||
icon := "●"
|
||
if thread.IsResolved {
|
||
icon = "✓"
|
||
} else if thread.IsOutdated {
|
||
icon = "○"
|
||
}
|
||
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
|
||
if m.unreadThreads[thread.ID] {
|
||
label := "NEW"
|
||
if m.newThreads[thread.ID] {
|
||
label = "NEW THREAD"
|
||
}
|
||
suffix += " " + warnStyle.Render(label)
|
||
}
|
||
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
|
||
path := truncatePath(thread.Path, pathWidth)
|
||
if m.pathScroll {
|
||
path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep)
|
||
}
|
||
line := icon + " " + pad(path, pathWidth) + suffix
|
||
line = ansi.Truncate(line, innerWidth, "")
|
||
if thread.IsResolved {
|
||
line = dimStyle.Render(line)
|
||
}
|
||
if i == m.threadIndex {
|
||
line = activeStyle.Render(pad(line, innerWidth))
|
||
}
|
||
lines = append(lines, line)
|
||
}
|
||
return renderPane(lines, width, height, m.focus == threadListPane)
|
||
}
|
||
|
||
func (m App) threadDetail(width, height int) string {
|
||
if len(m.details.Threads) == 0 {
|
||
return renderPane([]string{"No review threads."}, width, height, m.focus == threadDetailPane)
|
||
}
|
||
lines := m.renderedDetailLines(width)
|
||
viewportHeight := max(1, height-2)
|
||
maxScroll := max(0, len(lines)-viewportHeight)
|
||
scroll := min(m.scroll, maxScroll)
|
||
visible := lines[scroll:min(len(lines), scroll+viewportHeight)]
|
||
rendered := make([]string, 0, len(visible))
|
||
innerWidth := max(1, width-2)
|
||
for _, line := range visible {
|
||
railWidth := ansi.StringWidth(line.rail)
|
||
contentWidth := max(1, innerWidth-railWidth)
|
||
var renderedLine string
|
||
if line.suggestionChange != 0 {
|
||
renderedLine = suggestionHighlight(line.fixed, line.text, contentWidth, line.suggestionChange)
|
||
} else {
|
||
renderedLine = ansi.Truncate(line.fixed+line.text, contentWidth, "")
|
||
}
|
||
renderedLine = line.rail + renderedLine
|
||
if line.selected {
|
||
renderedLine = selectedBackground(renderedLine, innerWidth)
|
||
}
|
||
rendered = append(rendered, renderedLine)
|
||
}
|
||
return renderPane(rendered, width, height, m.focus == threadDetailPane)
|
||
}
|
||
|
||
type detailLine struct {
|
||
text string
|
||
fixed string
|
||
rail string
|
||
selected bool
|
||
suggestionChange byte
|
||
anchor string
|
||
}
|
||
|
||
func (m App) detailLines(width int) []detailLine {
|
||
if len(m.details.Threads) == 0 {
|
||
return nil
|
||
}
|
||
thread := m.details.Threads[m.threadIndex]
|
||
status := "open"
|
||
if thread.IsResolved {
|
||
status = "resolved"
|
||
}
|
||
if thread.IsOutdated {
|
||
status += ", outdated"
|
||
}
|
||
if thread.Origin == reviewOriginLocalAI {
|
||
status += ", LOCAL AI · LOCAL ONLY"
|
||
}
|
||
if m.unreadThreads[thread.ID] {
|
||
if m.newThreads[thread.ID] {
|
||
status += ", new thread"
|
||
} else {
|
||
status += ", new updates"
|
||
}
|
||
}
|
||
if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" {
|
||
status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID)
|
||
}
|
||
if pushedAt := latestForcePush(m.details.Timeline); !pushedAt.IsZero() &&
|
||
threadOpenedAt(thread).Before(pushedAt) {
|
||
status += ", predates latest force-push"
|
||
}
|
||
lines := []detailLine{
|
||
{anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)},
|
||
}
|
||
if m.folded[thread.ID] {
|
||
lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render(fmt.Sprintf(
|
||
"Thread folded. Press %s to expand.",
|
||
primaryCombinedKeyLabel(
|
||
m.keybindings.Threads.Toggle,
|
||
[]string{primarySequenceKeyLabel(
|
||
m.keybindings.Threads.FoldPrefix,
|
||
m.keybindings.Threads.FoldToggle,
|
||
)},
|
||
),
|
||
))})
|
||
} else {
|
||
if len(thread.Comments) > 0 {
|
||
lines = append(lines, detailLine{})
|
||
startLine, endLine := reviewAnchor(thread)
|
||
for codeIndex, codeLine := range highlightDiff(thread.Path, thread.Comments[0].DiffHunk, startLine, endLine, thread.DiffSide) {
|
||
wrapped := wrapDiffLine(codeLine, max(1, width-2))
|
||
for wrapIndex := range wrapped {
|
||
wrapped[wrapIndex].anchor = fmt.Sprintf("code:%d:%d", codeIndex, wrapIndex)
|
||
}
|
||
lines = append(lines, wrapped...)
|
||
}
|
||
}
|
||
unreadDivider := false
|
||
for _, comment := range thread.Comments {
|
||
commentUnread := m.unreadComments[comment.ID]
|
||
if commentUnread && !m.newThreads[thread.ID] && !unreadDivider {
|
||
lines = append(lines,
|
||
detailLine{},
|
||
detailLine{
|
||
anchor: "unread:" + comment.ID,
|
||
text: warnStyle.Render("── NEW MESSAGES ──"),
|
||
},
|
||
)
|
||
unreadDivider = true
|
||
}
|
||
content := parseCommentBody(comment.Body)
|
||
rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ")
|
||
if commentUnread && !m.newThreads[thread.ID] {
|
||
rail = warnStyle.Render("┃ ")
|
||
}
|
||
lines = append(lines, detailLine{}, detailLine{
|
||
rail: rail,
|
||
anchor: "comment:" + comment.ID + ":header",
|
||
text: m.commentAuthorText(comment) +
|
||
localAICommentBadge(comment) + " " +
|
||
dimStyle.Render(comment.CreatedAt.Local().Format("2006-01-02 15:04")),
|
||
})
|
||
if content.Prose != "" {
|
||
for lineIndex, commentLine := range renderCommentMarkdown(content.Prose, max(10, width-4)) {
|
||
lines = append(lines, detailLine{
|
||
rail: rail, text: commentLine,
|
||
anchor: fmt.Sprintf("comment:%s:body:%d", comment.ID, lineIndex),
|
||
})
|
||
}
|
||
}
|
||
for suggestionIndex, suggestion := range content.Suggestions {
|
||
removed, added := normalizeSuggestion(reviewedSourceLines(thread, comment), suggestion)
|
||
removedRanges, addedRanges := suggestionChangedRanges(removed, added)
|
||
label := "suggested change"
|
||
if len(content.Suggestions) > 1 {
|
||
label = fmt.Sprintf("suggested change %d/%d", suggestionIndex+1, len(content.Suggestions))
|
||
}
|
||
lines = append(lines, detailLine{rail: rail}, detailLine{rail: rail, text: dimStyle.Render(label)})
|
||
for sourceIndex, source := range removed {
|
||
lines = append(lines, addCommentRail(
|
||
wrapSuggestionLine(
|
||
thread.Path, source, '-', removedRanges[sourceIndex], max(1, width-4),
|
||
), rail,
|
||
)...)
|
||
}
|
||
for sourceIndex, source := range added {
|
||
lines = append(lines, addCommentRail(
|
||
wrapSuggestionLine(
|
||
thread.Path, source, '+', addedRanges[sourceIndex], max(1, width-4),
|
||
), rail,
|
||
)...)
|
||
}
|
||
}
|
||
for reactionIndex, reactionLine := range renderReactionSummary(
|
||
comment.Reactions, max(1, width-4),
|
||
) {
|
||
lines = append(lines, detailLine{
|
||
rail: rail, text: reactionLine,
|
||
anchor: fmt.Sprintf("comment:%s:reactions:%d", comment.ID, reactionIndex),
|
||
})
|
||
}
|
||
}
|
||
if thread.IsTruncated {
|
||
lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")})
|
||
}
|
||
}
|
||
if m.writeMode == writeReply && m.writeThreadID == thread.ID {
|
||
lines = append(lines, m.inlineReplyLines(width)...)
|
||
}
|
||
if m.aiMode == aiDiscussion && m.writeThreadID == thread.ID {
|
||
lines = append(lines, m.inlineAIDiscussionLines(width)...)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m App) renderedDetailLines(width int) []detailLine {
|
||
lines := m.detailLines(width)
|
||
innerWidth := max(1, width-2)
|
||
rendered := make([]detailLine, 0, len(lines))
|
||
for _, line := range lines {
|
||
contentWidth := max(1, innerWidth-ansi.StringWidth(line.rail))
|
||
content := line.fixed + line.text
|
||
if line.suggestionChange != 0 || ansi.StringWidth(content) <= contentWidth {
|
||
rendered = append(rendered, line)
|
||
continue
|
||
}
|
||
for index, wrapped := range strings.Split(
|
||
ansi.Hardwrap(content, contentWidth, true), "\n",
|
||
) {
|
||
continuation := line
|
||
continuation.fixed = ""
|
||
continuation.text = wrapped
|
||
if index > 0 && continuation.anchor != "" {
|
||
continuation.anchor += fmt.Sprintf(":wrap:%d", index)
|
||
}
|
||
rendered = append(rendered, continuation)
|
||
}
|
||
}
|
||
return rendered
|
||
}
|
||
|
||
func renderReactionSummary(reactions []ReactionSummary, width int) []string {
|
||
var badges []string
|
||
for _, reaction := range reactions {
|
||
if reaction.Count <= 0 {
|
||
continue
|
||
}
|
||
badge := fmt.Sprintf("%s %d", reactionEmoji(reaction.Content), reaction.Count)
|
||
if reaction.ViewerHasReacted {
|
||
badge = titleStyle.Render(badge)
|
||
} else {
|
||
badge = dimStyle.Render(badge)
|
||
}
|
||
badges = append(badges, badge)
|
||
}
|
||
if len(badges) == 0 {
|
||
return nil
|
||
}
|
||
|
||
lines := []string{}
|
||
current := ""
|
||
for _, badge := range badges {
|
||
candidate := badge
|
||
if current != "" {
|
||
candidate = current + " " + badge
|
||
}
|
||
if current != "" && ansi.StringWidth(candidate) > width {
|
||
lines = append(lines, current)
|
||
current = badge
|
||
} else {
|
||
current = candidate
|
||
}
|
||
}
|
||
if current != "" {
|
||
lines = append(lines, current)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func reactionEmoji(content string) string {
|
||
switch content {
|
||
case "THUMBS_UP":
|
||
return "👍"
|
||
case "THUMBS_DOWN":
|
||
return "👎"
|
||
case "LAUGH":
|
||
return "😄"
|
||
case "HOORAY":
|
||
return "🎉"
|
||
case "CONFUSED":
|
||
return "😕"
|
||
case "HEART":
|
||
return "❤️"
|
||
case "ROCKET":
|
||
return "🚀"
|
||
case "EYES":
|
||
return "👀"
|
||
default:
|
||
return ":" + strings.ToLower(content) + ":"
|
||
}
|
||
}
|
||
|
||
func (m App) inlineReplyLines(width int) []detailLine {
|
||
rail := warnStyle.Render("│ ")
|
||
lines := []detailLine{
|
||
{},
|
||
{rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
|
||
}
|
||
textWidth := max(1, width-5)
|
||
lineIndex := 0
|
||
for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) {
|
||
lines = append(lines, detailLine{
|
||
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
||
})
|
||
lineIndex++
|
||
}
|
||
if m.err != nil {
|
||
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
||
}
|
||
lines = append(lines, detailLine{
|
||
rail: rail,
|
||
text: dimStyle.Render(fmt.Sprintf(
|
||
"%s newline • %s review • %s cancel",
|
||
primaryKeyLabel(m.keybindings.Input.Newline),
|
||
primaryKeyLabel(m.keybindings.Input.Submit),
|
||
primaryKeyLabel(m.keybindings.Input.Cancel),
|
||
)),
|
||
})
|
||
return lines
|
||
}
|
||
|
||
func inputCursorFallback(hardwareCursor bool) string {
|
||
if hardwareCursor {
|
||
return ""
|
||
}
|
||
return "\x1b[4m \x1b[24m"
|
||
}
|
||
|
||
func renderTextInput(value string, width int, hardwareCursor bool) []string {
|
||
const cursorSentinel = "\ue000"
|
||
if hardwareCursor {
|
||
value += cursorSentinel
|
||
} else {
|
||
value += inputCursorFallback(false)
|
||
}
|
||
var lines []string
|
||
for _, sourceLine := range strings.Split(value, "\n") {
|
||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false)
|
||
if hardwareCursor {
|
||
wrapped = strings.TrimSuffix(wrapped, cursorSentinel)
|
||
}
|
||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func textInputKeyValue(key tea.KeyMsg) string {
|
||
if key.Type == tea.KeySpace {
|
||
return " "
|
||
}
|
||
return string(key.Runes)
|
||
}
|
||
|
||
func (m App) positionThreadInputHardwareCursor() {
|
||
if m.cursorOutput == nil {
|
||
return
|
||
}
|
||
topLines := m.threadTopLineCount()
|
||
if m.searching {
|
||
if m.width >= 70 && m.listHidden {
|
||
return
|
||
}
|
||
paneWidth := m.width
|
||
if m.width >= 70 {
|
||
paneWidth = m.threadListWidth()
|
||
}
|
||
queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1)
|
||
query := ansi.Truncate(m.searchQuery, queryWidth, "…")
|
||
m.cursorOutput.SetCursor(
|
||
true,
|
||
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query),
|
||
m.contentTop+topLines+3,
|
||
)
|
||
return
|
||
}
|
||
if m.writeMode != writeReply && m.aiMode != aiDiscussion {
|
||
return
|
||
}
|
||
width, height := m.detailPaneSize()
|
||
lines := m.renderedDetailLines(width)
|
||
prefix := "reply:body:"
|
||
if m.aiMode == aiDiscussion {
|
||
prefix = "ai-discussion:body:"
|
||
}
|
||
cursorLine := -1
|
||
for index := range lines {
|
||
if strings.HasPrefix(lines[index].anchor, prefix) {
|
||
cursorLine = index
|
||
}
|
||
}
|
||
if cursorLine < 0 {
|
||
return
|
||
}
|
||
viewportHeight := max(1, height-2)
|
||
scroll := min(m.scroll, max(0, len(lines)-viewportHeight))
|
||
screenLine := cursorLine - scroll
|
||
if screenLine < 0 || screenLine >= viewportHeight {
|
||
return
|
||
}
|
||
paneStart := 0
|
||
if m.width >= 70 && !m.listHidden {
|
||
paneStart = m.threadListWidth() + 1
|
||
}
|
||
line := lines[cursorLine]
|
||
m.cursorOutput.SetCursor(
|
||
true,
|
||
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text),
|
||
m.contentTop+topLines+2+screenLine,
|
||
)
|
||
}
|
||
|
||
func latestForcePush(events []TimelineEvent) time.Time {
|
||
var latest time.Time
|
||
for _, event := range events {
|
||
if event.Kind == "force-push" && event.CreatedAt.After(latest) {
|
||
latest = event.CreatedAt
|
||
}
|
||
}
|
||
return latest
|
||
}
|
||
|
||
func (m App) detailScrollAnchor() string {
|
||
width, _ := m.detailPaneSize()
|
||
lines := m.renderedDetailLines(width)
|
||
for index := min(m.scroll, len(lines)-1); index >= 0; index-- {
|
||
if lines[index].anchor != "" {
|
||
return lines[index].anchor
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m *App) restoreDetailAnchor(anchor string) {
|
||
width, _ := m.detailPaneSize()
|
||
for index, line := range m.renderedDetailLines(width) {
|
||
if line.anchor == anchor {
|
||
m.scroll = min(index, m.detailMaxScroll())
|
||
return
|
||
}
|
||
}
|
||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||
}
|
||
|
||
func wrapDiffLine(line highlightedDiffLine, width int) []detailLine {
|
||
gutterWidth := ansi.StringWidth(line.gutter)
|
||
if gutterWidth == 0 {
|
||
wrapped := ansi.Hardwrap(line.code, width, true)
|
||
parts := strings.Split(wrapped, "\n")
|
||
result := make([]detailLine, 0, len(parts))
|
||
for _, part := range parts {
|
||
result = append(result, detailLine{text: part, selected: line.selected})
|
||
}
|
||
return result
|
||
}
|
||
|
||
codeWidth := max(1, width-gutterWidth)
|
||
parts := wrapCodeWithIndent(line.code, codeWidth)
|
||
result := make([]detailLine, 0, len(parts))
|
||
for i, part := range parts {
|
||
gutter := line.gutter
|
||
if i > 0 {
|
||
gutter = strings.Repeat(" ", gutterWidth)
|
||
}
|
||
result = append(result, detailLine{
|
||
text: part, fixed: gutter, selected: line.selected,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func wrapSuggestionLine(path, source string, change byte, changed codeRange, width int) []detailLine {
|
||
marker := badStyle.Render(string(change))
|
||
if change == '+' {
|
||
marker = okStyle.Render(string(change))
|
||
}
|
||
code := highlightedSource(lexerForPath(path), source)
|
||
code = highlightCodeRange(code, changed, change)
|
||
lines := wrapDiffLine(highlightedDiffLine{
|
||
gutter: marker + " ",
|
||
code: code,
|
||
}, width)
|
||
for i := range lines {
|
||
lines[i].suggestionChange = change
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func highlightCodeRange(code string, changed codeRange, change byte) string {
|
||
if changed.End <= changed.Start {
|
||
return code
|
||
}
|
||
totalWidth := ansi.StringWidth(code)
|
||
start := clamp(changed.Start, 0, totalWidth)
|
||
end := clamp(changed.End, start, totalWidth)
|
||
if end <= start {
|
||
return code
|
||
}
|
||
|
||
background := changedRemoveBackground
|
||
if change == '+' {
|
||
background = changedAddBackground
|
||
}
|
||
if background == "" {
|
||
return code
|
||
}
|
||
const reset = "\x1b[0m"
|
||
before := ansi.Cut(code, 0, start)
|
||
middle := ansi.Cut(code, start, end)
|
||
after := ansi.Cut(code, end, totalWidth)
|
||
middle = strings.ReplaceAll(middle, reset, reset+background)
|
||
return before + background + middle + reset + after
|
||
}
|
||
|
||
func addCommentRail(lines []detailLine, rail string) []detailLine {
|
||
for i := range lines {
|
||
lines[i].rail = rail
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func wrapCodeWithIndent(code string, width int) []string {
|
||
if width <= 0 || ansi.StringWidth(code) <= width {
|
||
return []string{code}
|
||
}
|
||
|
||
plain := ansi.Strip(code)
|
||
indent := len(plain) - len(strings.TrimLeft(plain, " "))
|
||
totalWidth := ansi.StringWidth(code)
|
||
continuationPrefix := strings.Repeat(" ", indent+2) + dimStyle.Render("↳ ")
|
||
continuationWidth := max(1, width-ansi.StringWidth(continuationPrefix))
|
||
parts := make([]string, 0, totalWidth/width+1)
|
||
offset := 0
|
||
for offset < totalWidth {
|
||
available := width
|
||
prefix := ""
|
||
if offset > 0 {
|
||
available = continuationWidth
|
||
prefix = continuationPrefix
|
||
}
|
||
end := syntaxBreakColumn(plain, offset, available)
|
||
if end <= offset {
|
||
end = min(totalWidth, offset+available)
|
||
}
|
||
parts = append(parts, prefix+ansi.Cut(code, offset, end))
|
||
offset = end
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func syntaxBreakColumn(code string, start, width int) int {
|
||
limit := start + width
|
||
column := 0
|
||
best := -1
|
||
for _, r := range code {
|
||
column += ansi.StringWidth(string(r))
|
||
if column <= start {
|
||
continue
|
||
}
|
||
if column > limit {
|
||
break
|
||
}
|
||
if isCodeBreakpoint(r) {
|
||
best = column
|
||
}
|
||
}
|
||
if best > start {
|
||
return best
|
||
}
|
||
return min(ansi.StringWidth(code), limit)
|
||
}
|
||
|
||
func isCodeBreakpoint(r rune) bool {
|
||
if r == ' ' || r == '\t' {
|
||
return true
|
||
}
|
||
return strings.ContainsRune(",.;:()[]{}+-*/%=<>|&", r)
|
||
}
|
||
|
||
func reviewAnchor(thread ReviewThread) (int, int) {
|
||
if len(thread.Comments) > 0 {
|
||
return commentReviewAnchor(thread, thread.Comments[0])
|
||
}
|
||
return commentReviewAnchor(thread, ReviewComment{})
|
||
}
|
||
|
||
func shortOID(oid string) string {
|
||
if len(oid) <= 7 {
|
||
return oid
|
||
}
|
||
return oid[:7]
|
||
}
|
||
|
||
func selectedBackground(line string, width int) string {
|
||
const reset = "\x1b[0m"
|
||
background := selectedLineBackground
|
||
line = pad(ansi.Truncate(line, width, ""), width)
|
||
if background == "" {
|
||
return line
|
||
}
|
||
line = strings.ReplaceAll(line, reset, reset+background)
|
||
return background + line + reset
|
||
}
|
||
|
||
func suggestionHighlight(gutter, code string, width int, change byte) string {
|
||
background := suggestionRemoveBackground
|
||
if change == '+' {
|
||
background = suggestionAddBackground
|
||
}
|
||
if background == "" {
|
||
return pad(ansi.Truncate(gutter+code, width, ""), width)
|
||
}
|
||
const reset = "\x1b[0m"
|
||
gutter = ansi.Truncate(gutter, width, "")
|
||
gutter = strings.ReplaceAll(gutter, reset, reset+background)
|
||
codeWidth := max(0, width-ansi.StringWidth(gutter))
|
||
code = ansi.Truncate(code, codeWidth, "")
|
||
code = strings.ReplaceAll(code, reset, reset+background)
|
||
content := background + gutter + code + reset
|
||
padding := max(0, width-ansi.StringWidth(gutter)-ansi.StringWidth(code))
|
||
if padding > 0 {
|
||
content += background + strings.Repeat(" ", padding) + reset
|
||
}
|
||
return content
|
||
}
|
||
|
||
var authorPalette = []lipgloss.Color{
|
||
"#61AFEF", "#C678DD", "#56B6C2", "#E5C07B",
|
||
"#E06C75", "#98C379", "#D19A66", "#7FC8FF",
|
||
}
|
||
|
||
func authorStyle(login string) lipgloss.Style {
|
||
return lipgloss.NewStyle().Bold(true).Foreground(authorColor(login))
|
||
}
|
||
|
||
func (m App) viewerLogin() string {
|
||
if m.details.ViewerLogin != "" {
|
||
return m.details.ViewerLogin
|
||
}
|
||
if m.details.ViewerAuthored {
|
||
return m.details.Author
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m App) displayAuthor(login string) string {
|
||
if m.viewerLabel == "you" && strings.EqualFold(login, m.viewerLogin()) {
|
||
return "you"
|
||
}
|
||
return login
|
||
}
|
||
|
||
func (m App) authorText(login string) string {
|
||
return authorStyle(login).Render("@" + m.displayAuthor(login))
|
||
}
|
||
|
||
func (m App) commentAuthorText(comment ReviewComment) string {
|
||
login := comment.Author
|
||
if comment.Origin == reviewOriginLocalAIUser && m.viewerLogin() != "" {
|
||
login = m.viewerLogin()
|
||
}
|
||
return m.authorText(login)
|
||
}
|
||
|
||
func authorColor(login string) lipgloss.Color {
|
||
hash := fnv.New32a()
|
||
_, _ = hash.Write([]byte(strings.ToLower(login)))
|
||
return authorPalette[int(hash.Sum32())%len(authorPalette)]
|
||
}
|
||
|
||
func (m App) frame(lines []string, help string) string {
|
||
body := strings.Join(lines, "\n")
|
||
status := ""
|
||
if m.err != nil {
|
||
status = badStyle.Render("error: " + truncate(m.err.Error(), max(20, m.width-8)))
|
||
}
|
||
// 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"))
|
||
}
|
||
footer := truncate(help, m.width)
|
||
if status != "" {
|
||
footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status
|
||
}
|
||
bodyLines := strings.Split(body, "\n")
|
||
if len(bodyLines) > max(0, m.height-1) {
|
||
bodyLines = bodyLines[:max(0, m.height-1)]
|
||
}
|
||
for i := range bodyLines {
|
||
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
|
||
}
|
||
bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, ""))
|
||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
|
||
}
|
||
|
||
func (m App) restingDiffletState() diffletState {
|
||
if m.err != nil {
|
||
return diffletSad
|
||
}
|
||
if m.loading || m.secondaryLoading {
|
||
return diffletLoading
|
||
}
|
||
if m.details.Number == 0 {
|
||
if !m.lastRefresh.IsZero() && len(m.prs) == 0 {
|
||
return diffletSleeping
|
||
}
|
||
return diffletIdle
|
||
}
|
||
if m.details.ReviewDecision == "APPROVED" {
|
||
return diffletApproved
|
||
}
|
||
open, outdated, resolved := threadStatusCounts(m.details.Threads)
|
||
if open+outdated > 0 {
|
||
return diffletConcerned
|
||
}
|
||
if resolved > 0 {
|
||
return diffletHappy
|
||
}
|
||
if m.screen == threadScreen {
|
||
return diffletFocused
|
||
}
|
||
return diffletIdle
|
||
}
|
||
|
||
func renderPane(lines []string, width, height int, active bool) string {
|
||
innerWidth := max(1, width-2)
|
||
innerHeight := max(1, height-2)
|
||
if len(lines) > innerHeight {
|
||
lines = lines[:innerHeight]
|
||
}
|
||
for i := range lines {
|
||
lines[i] = ansi.Truncate(lines[i], innerWidth, "")
|
||
}
|
||
return paneStyle(active).Width(innerWidth).Height(innerHeight).Render(strings.Join(lines, "\n"))
|
||
}
|
||
|
||
func reviewAndMergeState(pr PRDetails) string {
|
||
if pr.Merged {
|
||
return okStyle.Render("merged")
|
||
}
|
||
switch pr.ReviewDecision {
|
||
case "APPROVED":
|
||
review := okStyle.Render("review: approved")
|
||
open, outdated, _ := threadStatusCounts(pr.Threads)
|
||
if pr.Requirements.RequiresConversation && open+outdated > 0 {
|
||
return review + " " + warnStyle.Render("merge: unresolved conversations")
|
||
}
|
||
if pr.Requirements.RequiresStatusChecks &&
|
||
(pr.CheckState == "FAILURE" || pr.CheckState == "ERROR") {
|
||
return review + " " + badStyle.Render("merge: checks failing")
|
||
}
|
||
switch pr.Mergeable {
|
||
case "MERGEABLE":
|
||
return review + " " + okStyle.Render("merge: ready")
|
||
case "CONFLICTING":
|
||
return review + " " + badStyle.Render("merge: conflicts")
|
||
default:
|
||
return review + " " + warnStyle.Render("merge: checking")
|
||
}
|
||
case "CHANGES_REQUESTED":
|
||
return badStyle.Render("review: changes requested")
|
||
case "REVIEW_REQUIRED":
|
||
return warnStyle.Render("review: required")
|
||
default:
|
||
return warnStyle.Render("review: pending")
|
||
}
|
||
}
|
||
|
||
func conflictStateText(pr PRDetails) string {
|
||
switch pr.Mergeable {
|
||
case "MERGEABLE":
|
||
return okStyle.Render("none")
|
||
case "CONFLICTING":
|
||
if len(pr.ConflictFiles) > 0 {
|
||
return badStyle.Render(fmt.Sprintf("%d conflicting files", len(pr.ConflictFiles)))
|
||
}
|
||
if pr.ConflictFileError != "" {
|
||
return badStyle.Render("detected") + " " +
|
||
warnStyle.Render("file scan unavailable: "+firstLine(pr.ConflictFileError))
|
||
}
|
||
return badStyle.Render("detected") + " " +
|
||
dimStyle.Render("no individual file paths reported")
|
||
default:
|
||
return warnStyle.Render("checking")
|
||
}
|
||
}
|
||
|
||
func (m App) autoMergeStateText(pr PRDetails) string {
|
||
if pr.Merged {
|
||
return okStyle.Render("merged")
|
||
}
|
||
if pr.AutoMerge == nil {
|
||
return dimStyle.Render("disabled")
|
||
}
|
||
text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod)
|
||
if pr.AutoMerge.EnabledBy != "" {
|
||
text += " by " + m.authorText(pr.AutoMerge.EnabledBy)
|
||
}
|
||
return text
|
||
}
|
||
|
||
func firstLine(value string) string {
|
||
if line, _, found := strings.Cut(value, "\n"); found {
|
||
return line
|
||
}
|
||
return value
|
||
}
|
||
|
||
func coloredState(state string) string {
|
||
switch state {
|
||
case "SUCCESS", "EXPECTED", "COMPLETED", "NEUTRAL", "SKIPPED":
|
||
return okStyle.Render(state)
|
||
case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE":
|
||
return badStyle.Render(state)
|
||
default:
|
||
return warnStyle.Render(state)
|
||
}
|
||
}
|
||
|
||
func (m App) reviewersText(reviewers []Reviewer) string {
|
||
if len(reviewers) == 0 {
|
||
return "none"
|
||
}
|
||
items := make([]string, 0, len(reviewers))
|
||
for _, reviewer := range reviewers {
|
||
items = append(items,
|
||
m.authorText(reviewer.Login)+
|
||
dimStyle.Render(" ("+strings.ToLower(reviewer.State)+")"),
|
||
)
|
||
}
|
||
return strings.Join(items, ", ")
|
||
}
|
||
func (m App) handlesText(items []string) string {
|
||
if len(items) == 0 {
|
||
return "none"
|
||
}
|
||
handles := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
handles = append(handles, m.authorText(item))
|
||
}
|
||
return strings.Join(handles, ", ")
|
||
}
|
||
func indexPR(items []PullRequest, id string) int {
|
||
for i, item := range items {
|
||
if item.ID == id {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
func indexThread(items []ReviewThread, id string) int {
|
||
for i, item := range items {
|
||
if item.ID == id {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
func clamp(value, low, high int) int {
|
||
if high < low {
|
||
return low
|
||
}
|
||
return min(max(value, low), high)
|
||
}
|
||
func windowStart(index, count, size int) int {
|
||
if count <= size {
|
||
return 0
|
||
}
|
||
return clamp(index-size/2, 0, count-size)
|
||
}
|
||
func truncate(s string, width int) string {
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
return ansi.Truncate(strings.ReplaceAll(s, "\n", " "), width, "…")
|
||
}
|
||
func truncatePath(path string, width int) string {
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
pathWidth := ansi.StringWidth(path)
|
||
if pathWidth <= width {
|
||
return path
|
||
}
|
||
if width == 1 {
|
||
return "…"
|
||
}
|
||
return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth)
|
||
}
|
||
|
||
func scrollingPath(path string, width, step int) string {
|
||
const pauseSteps = 4
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
pathWidth := ansi.StringWidth(path)
|
||
if pathWidth <= width {
|
||
return path
|
||
}
|
||
if width == 1 {
|
||
return "…"
|
||
}
|
||
|
||
visibleWidth := width - 1
|
||
maxOffset := pathWidth - visibleWidth
|
||
cycle := pauseSteps + maxOffset + pauseSteps
|
||
phase := step % cycle
|
||
offset := 0
|
||
switch {
|
||
case phase < pauseSteps:
|
||
offset = 0
|
||
case phase < pauseSteps+maxOffset:
|
||
offset = phase - pauseSteps
|
||
default:
|
||
offset = maxOffset
|
||
}
|
||
if offset == 0 {
|
||
return ansi.Cut(path, 0, visibleWidth) + "…"
|
||
}
|
||
if offset == maxOffset {
|
||
return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth)
|
||
}
|
||
return "…" + ansi.Cut(path, offset, offset+width-2) + "…"
|
||
}
|
||
|
||
func fuzzyPathScore(path, query string) (int, bool) {
|
||
candidate := []rune(strings.ToLower(path))
|
||
terms := strings.Fields(strings.ToLower(query))
|
||
if len(terms) == 0 {
|
||
return 0, true
|
||
}
|
||
|
||
total := 0
|
||
for _, term := range terms {
|
||
score, ok := fuzzyTermScore(candidate, []rune(term))
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
total += score
|
||
}
|
||
return total, true
|
||
}
|
||
|
||
func fuzzyTermScore(candidate, needle []rune) (int, bool) {
|
||
if start := strings.Index(string(candidate), string(needle)); start >= 0 {
|
||
return 10000 - start*10 - len(candidate), true
|
||
}
|
||
|
||
score, candidateIndex, previous := 0, 0, -2
|
||
for _, wanted := range needle {
|
||
found := -1
|
||
for candidateIndex < len(candidate) {
|
||
if candidate[candidateIndex] == wanted {
|
||
found = candidateIndex
|
||
candidateIndex++
|
||
break
|
||
}
|
||
candidateIndex++
|
||
}
|
||
if found < 0 {
|
||
return 0, false
|
||
}
|
||
score += 20
|
||
if found == previous+1 {
|
||
score += 15
|
||
}
|
||
if found == 0 || strings.ContainsRune("/._-", candidate[found-1]) {
|
||
score += 12
|
||
}
|
||
score -= found
|
||
previous = found
|
||
}
|
||
return score - len(candidate), true
|
||
}
|
||
|
||
func pad(s string, width int) string {
|
||
n := width - lipgloss.Width(s)
|
||
if n <= 0 {
|
||
return truncate(s, width)
|
||
}
|
||
return s + strings.Repeat(" ", n)
|
||
}
|
||
func wrap(text string, width int) string {
|
||
words := strings.Fields(text)
|
||
if len(words) == 0 {
|
||
return ""
|
||
}
|
||
lines, line := []string{}, words[0]
|
||
for _, word := range words[1:] {
|
||
if len([]rune(line))+1+len([]rune(word)) > width {
|
||
lines, line = append(lines, line), word
|
||
} else {
|
||
line += " " + word
|
||
}
|
||
}
|
||
return strings.Join(append(lines, line), "\n")
|
||
}
|