Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions internal/cli/picker.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"context"
"fmt"
"os"
"os/exec"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/mattn/go-runewidth"
"github.com/sendbird/ccx/internal/extract"
"github.com/sendbird/ccx/internal/kitty"
"github.com/sendbird/ccx/internal/session"
)

var pickerDiffStyles = extract.DiffStyles{
Expand Down Expand Up @@ -70,6 +72,11 @@ type pickerModel struct {
// events, so we only ask once per session.
focusReportingEnabled bool

// refStatus holds resolved PR/Jira status keyed by URL, filled
// asynchronously after the picker opens (urls kind only) so PR/Jira rows
// show OPEN/MERGED/review/checks inline.
refStatus map[string]session.SessionRef

result *PickerResult
quit bool
}
Expand All @@ -83,10 +90,48 @@ func newPickerModel(kind string, items []PickerItem) pickerModel {
artifactSelected: make(map[int]bool),
previewMode: pickerPreviewConversation,
termFocused: true,
refStatus: make(map[string]session.SessionRef),
}
}

func (m pickerModel) Init() tea.Cmd { return nil }
// pickerRefStatusMsg carries one resolved PR/Jira status back into the picker.
type pickerRefStatusMsg struct {
ref session.SessionRef
}

func (m pickerModel) Init() tea.Cmd {
return m.resolveRefsCmd()
}

// resolveRefsCmd asynchronously resolves PR/Jira status for every GitHub-PR or
// Jira URL in the list, streaming each result back as a pickerRefStatusMsg. It
// reuses session.ResolveRef so results are TTL-cached and share the bounded
// concurrency used elsewhere. Non-urls kinds have no PR/Jira links → no-op.
func (m pickerModel) resolveRefsCmd() tea.Cmd {
if m.kind != "urls" {
return nil
}
var cmds []tea.Cmd
for _, it := range m.allItems {
if it.Item.Category != "pr" && it.Item.Category != "jira" {
continue
}
ref, ok := session.ClassifyURLRef(it.Item.URL)
if !ok {
continue
}
r := ref
cmds = append(cmds, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return pickerRefStatusMsg{ref: session.ResolveRef(ctx, r)}
})
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}

// kittyPickerTickMsg fires every 250ms when Kitty is supported so we can
// poll tmux pane visibility — tmux does NOT forward focus-out events on
Expand All @@ -101,6 +146,11 @@ func kittyPickerTickCmd() tea.Cmd {

func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case pickerRefStatusMsg:
if m.refStatus != nil {
m.refStatus[msg.ref.URL] = msg.ref
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
Expand Down Expand Up @@ -706,10 +756,11 @@ func (m pickerModel) View() string {
if len(item.Refs) > 1 {
refBadge = dim.Render(fmt.Sprintf(" ×%d", len(item.Refs)))
}
statusBadge := m.refStatusText(item.Item.URL, dim)
if i == m.cursor {
listLines = append(listLines, sel.Render(">")+check+badge+" "+sel.Render(label)+refBadge)
listLines = append(listLines, sel.Render(">")+check+badge+" "+sel.Render(label)+refBadge+statusBadge)
} else {
listLines = append(listLines, " "+check+badge+" "+dim.Render(label)+refBadge)
listLines = append(listLines, " "+check+badge+" "+dim.Render(label)+refBadge+statusBadge)
}
}

Expand Down Expand Up @@ -901,6 +952,23 @@ func (m pickerModel) selectedURLs() []string {
return nil
}

// refStatusText returns the styled PR/Jira status suffix (leading two spaces)
// for a URL row, or "" when the URL has no resolved PR/Jira status.
func (m pickerModel) refStatusText(url string, dim lipgloss.Style) string {
if m.refStatus == nil {
return ""
}
ref, ok := m.refStatus[url]
if !ok {
return ""
}
txt := session.RefStatusText(ref)
if txt == "" {
return ""
}
return " " + dim.Render(txt)
}

func (m pickerModel) openItems(urls []string) {
for _, u := range urls {
var cmd *exec.Cmd
Expand Down
72 changes: 72 additions & 0 deletions internal/session/refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,78 @@ func classifyRef(u string) (SessionRef, bool) {
return SessionRef{}, false
}

// ClassifyURLRef turns a raw URL into a SessionRef if it is a GitHub PR or a
// Jira browse link, returning ok=false otherwise. Exported so URL menus (which
// classify URLs by host on their own) can reuse the same PR/Jira detection the
// References preview uses, then resolve status via ResolveRef.
func ClassifyURLRef(u string) (SessionRef, bool) {
return classifyRef(u)
}

// RefStatusText returns a compact, plain-text (no ANSI) status summary for a
// resolved ref, suitable for URL-menu rows and `--plain` output. Returns "" when
// nothing is known yet (unresolved and no state), or "…" while a resolve is in
// flight. Examples: "OPEN · approved · checks ✓", "MERGED", "In Progress".
func RefStatusText(r SessionRef) string {
switch r.Kind {
case RefPR:
var parts []string
if r.State != RefStateUnknown {
parts = append(parts, string(r.State))
}
if r.ReviewDecision != "" {
parts = append(parts, reviewText(r.ReviewDecision))
}
if r.ChecksState != "" {
parts = append(parts, checksText(r.ChecksState))
}
if len(parts) == 0 {
if r.Resolved {
return ""
}
return "…"
}
return strings.Join(parts, " · ")
case RefJira:
if r.JiraStatus != "" {
return r.JiraStatus
}
if r.Resolved {
return ""
}
return "…"
}
return ""
}

// reviewText renders a PR review decision as plain text.
func reviewText(d string) string {
switch d {
case "APPROVED":
return "approved"
case "CHANGES_REQUESTED":
return "changes requested"
case "REVIEW_REQUIRED":
return "review required"
default:
return strings.ToLower(d)
}
}

// checksText renders a rolled-up PR checks state as plain text.
func checksText(s string) string {
switch s {
case "SUCCESS":
return "checks ✓"
case "FAILURE", "ERROR":
return "checks ✗"
case "PENDING":
return "checks …"
default:
return "checks " + strings.ToLower(s)
}
}

// prNumber returns the leading digits of a PR URL's number segment, or "" if the
// segment is not numeric (e.g. "new" in a "/pull/new/<branch>" compare URL).
func prNumber(u string) string {
Expand Down
37 changes: 37 additions & 0 deletions internal/session/refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,43 @@ func TestClassifyRef(t *testing.T) {
}
}

// TestClassifyURLRef guards the exported wrapper URL menus use to detect PR/Jira
// links; it must agree with the internal classifyRef.
func TestClassifyURLRef(t *testing.T) {
if ref, ok := ClassifyURLRef("https://github.com/sendbird/ccx/pull/52"); !ok || ref.Kind != RefPR || ref.Label != "sendbird/ccx#52" {
t.Errorf("PR: got %+v ok=%v", ref, ok)
}
if ref, ok := ClassifyURLRef("https://sendbird.atlassian.net/browse/CPLAT-9497"); !ok || ref.Kind != RefJira || ref.Label != "CPLAT-9497" {
t.Errorf("Jira: got %+v ok=%v", ref, ok)
}
if _, ok := ClassifyURLRef("https://example.com/foo"); ok {
t.Error("non-PR/Jira URL should not classify")
}
}

// TestRefStatusText covers the plain-text status summary shown in URL-menu rows.
func TestRefStatusText(t *testing.T) {
cases := []struct {
name string
ref SessionRef
want string
}{
{"pr unresolved", SessionRef{Kind: RefPR}, "…"},
{"pr resolved no-status", SessionRef{Kind: RefPR, Resolved: true}, ""},
{"pr open approved success", SessionRef{Kind: RefPR, State: RefStateOpen, ReviewDecision: "APPROVED", ChecksState: "SUCCESS", Resolved: true}, "OPEN · approved · checks ✓"},
{"pr merged", SessionRef{Kind: RefPR, State: RefStateMerged, Resolved: true}, "MERGED"},
{"pr changes failing", SessionRef{Kind: RefPR, State: RefStateOpen, ReviewDecision: "CHANGES_REQUESTED", ChecksState: "FAILURE", Resolved: true}, "OPEN · changes requested · checks ✗"},
{"jira unresolved", SessionRef{Kind: RefJira}, "…"},
{"jira in progress", SessionRef{Kind: RefJira, JiraStatus: "In Progress", Resolved: true}, "In Progress"},
{"jira resolved no-status", SessionRef{Kind: RefJira, Resolved: true}, ""},
}
for _, c := range cases {
if got := RefStatusText(c.ref); got != c.want {
t.Errorf("%s: got %q want %q", c.name, got, c.want)
}
}
}

func TestSessionRefIsOpen(t *testing.T) {
cases := []struct {
ref SessionRef
Expand Down
20 changes: 20 additions & 0 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ type App struct {
urlSearchTerm string
urlScope string // context label: "session", "message", "block"

// PR/Jira status for URL-menu rows, keyed by URL. Filled asynchronously
// when the menu opens (same gh/Jira resolve + TTL cache the References
// preview uses) so PR/Jira links show OPEN/MERGED/review/checks inline.
urlRefStatus map[string]session.SessionRef

// Changes-specific state for diff preview
urlChangeMap map[string]extract.ChangeItem // file path → ChangeItem (for diff rendering)
urlDiffVP viewport.Model // scrollable diff viewport
Expand Down Expand Up @@ -1199,6 +1204,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil

case urlRefStatusMsg:
// One PR/Jira URL's status landed; store it so the URL menu row renders
// the state inline. Ignore if the menu was closed in the meantime.
if a.urlRefStatus != nil {
a.urlRefStatus[msg.ref.URL] = msg.ref
}
return a, nil

case tea.MouseMsg:
return a.handleMouse(msg)

Expand Down Expand Up @@ -7720,6 +7733,13 @@ type refStatusMsg struct {
ref session.SessionRef
}

// urlRefStatusMsg carries the resolved status of a single PR/Jira URL shown in
// the URL menu, keyed by URL rather than session so it streams into the menu
// (which is not scoped to a session) one ref at a time.
type urlRefStatusMsg struct {
ref session.SessionRef
}

// PR/Jira status is resolved on demand only for the session whose References
// preview is open (updateSessionRefsPreview → resolveRefsStatusCmd, streaming
// back as refStatusMsg). An earlier fleet-wide background sweep ran `gh pr view`
Expand Down
60 changes: 57 additions & 3 deletions internal/tui/urls.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tui

import (
"context"
"fmt"
"strings"
"time"

"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
Expand Down Expand Up @@ -279,7 +281,39 @@ func (a *App) openURLMenuFromItems(items []extract.Item, scope string) (tea.Mode
a.urlSearching = false
a.urlSearchTerm = ""
a.urlScope = scope
return a, nil
a.urlRefStatus = make(map[string]session.SessionRef)
return a, a.resolveURLRefsCmd(items)
}

// resolveURLRefsCmd asynchronously resolves PR/Jira status for every GitHub-PR
// or Jira URL in the menu, streaming each result back as a urlRefStatusMsg. It
// reuses session.ResolveRef, so results are TTL-cached and share the same
// bounded concurrency as the References preview. File/changes scopes contain no
// PR/Jira links, so this is a no-op for them.
func (a *App) resolveURLRefsCmd(items []extract.Item) tea.Cmd {
if a.isFileScope() {
return nil
}
var cmds []tea.Cmd
for _, it := range items {
if it.Category != "pr" && it.Category != "jira" {
continue
}
ref, ok := session.ClassifyURLRef(it.URL)
if !ok {
continue
}
r := ref
cmds = append(cmds, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return urlRefStatusMsg{ref: session.ResolveRef(ctx, r)}
})
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}

// handleURLMenu processes key events while the URL menu is open.
Expand Down Expand Up @@ -439,6 +473,25 @@ func (a *App) closeURLMenu() {
a.worktreeAlignActive = false
a.urlChangeMap = nil
a.urlDiffReady = false
a.urlRefStatus = nil
}

// urlRefStatusText returns the styled, right-hand PR/Jira status suffix for a
// URL-menu row (leading with two spaces), or "" when the URL is not a resolved
// PR/Jira link. A resolve in flight renders a dim "…".
func (a *App) urlRefStatusText(url string) string {
if a.urlRefStatus == nil {
return ""
}
ref, ok := a.urlRefStatus[url]
if !ok {
return ""
}
txt := session.RefStatusText(ref)
if txt == "" {
return ""
}
return " " + dimStyle.Render(txt)
}

// isFileScope returns true when the URL menu is showing file-like paths, not URLs.
Expand Down Expand Up @@ -595,10 +648,11 @@ func (a *App) renderURLMenu() string {
if a.urlSelected[item.URL] {
check = sel.Render("* ")
}
status := a.urlRefStatusText(item.URL)
if i == cursor {
lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label))
lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)+status)
} else {
lines = append(lines, " "+check+badge+" "+hl.Render(label))
lines = append(lines, " "+check+badge+" "+hl.Render(label)+status)
}
}

Expand Down
Loading