4584 lines
124 KiB
Go
4584 lines
124 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/mattn/go-isatty"
|
|
"github.com/openclaw/gitcrawl/internal/store"
|
|
"github.com/openclaw/gitcrawl/internal/vector"
|
|
)
|
|
|
|
var (
|
|
markdownLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
|
bareLinkRE = regexp.MustCompile(`(^|[\s(<])(https?://[^\s<>)]+)`)
|
|
markdownHeadingRE = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
|
|
markdownListRE = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.+)$`)
|
|
terminalControlRE = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`)
|
|
summaryKeyOrder = []string{"key_summary", "problem_summary", "solution_summary", "maintainer_signal_summary", "dedupe_summary"}
|
|
)
|
|
|
|
const tuiAutoRefreshInterval = 15 * time.Second
|
|
const tuiWheelScrollDelay = 16 * time.Millisecond
|
|
const tuiWheelMaxBufferedDelta = 6
|
|
const tuiWheelSettleDelay = 90 * time.Millisecond
|
|
|
|
type tuiAutoRefreshMsg struct{}
|
|
type tuiRemoteRefreshTickMsg struct{}
|
|
type tuiWheelScrollMsg struct {
|
|
seq int
|
|
}
|
|
type tuiWheelSettledMsg struct {
|
|
seq int
|
|
}
|
|
|
|
type tuiRemoteRefreshMsg struct {
|
|
changed bool
|
|
err error
|
|
}
|
|
|
|
type clusterBrowserPayload struct {
|
|
Repository string `json:"repository"`
|
|
InferredRepository bool `json:"inferred_repository"`
|
|
Mode string `json:"mode"`
|
|
DBSource string `json:"db_source,omitempty"`
|
|
DBLocation string `json:"db_location,omitempty"`
|
|
DBRefreshSource string `json:"-"`
|
|
DBRuntimePath string `json:"-"`
|
|
Sort string `json:"sort"`
|
|
MinSize int `json:"min_size"`
|
|
Limit int `json:"limit,omitempty"`
|
|
HideClosed bool `json:"hide_closed,omitempty"`
|
|
EmbedModel string `json:"embed_model,omitempty"`
|
|
EmbeddingBasis string `json:"embedding_basis,omitempty"`
|
|
Clusters []store.ClusterSummary `json:"clusters"`
|
|
}
|
|
|
|
type tuiFocus string
|
|
|
|
const (
|
|
focusClusters tuiFocus = "clusters"
|
|
focusMembers tuiFocus = "members"
|
|
focusDetail tuiFocus = "detail"
|
|
)
|
|
|
|
type tuiMemberSort string
|
|
|
|
const (
|
|
memberSortKind tuiMemberSort = "kind"
|
|
memberSortRecent tuiMemberSort = "recent"
|
|
memberSortOldest tuiMemberSort = "oldest"
|
|
memberSortNumber tuiMemberSort = "number"
|
|
memberSortState tuiMemberSort = "state"
|
|
memberSortTitle tuiMemberSort = "title"
|
|
)
|
|
|
|
type tuiWideLayout string
|
|
|
|
const (
|
|
wideLayoutColumns tuiWideLayout = "columns"
|
|
wideLayoutRightStack tuiWideLayout = "right-stack"
|
|
)
|
|
|
|
type tuiRect struct {
|
|
x int
|
|
y int
|
|
w int
|
|
h int
|
|
}
|
|
|
|
type clusterBrowserModel struct {
|
|
payload clusterBrowserPayload
|
|
allClusters []store.ClusterSummary
|
|
ctx context.Context
|
|
store *store.Store
|
|
repoID int64
|
|
focus tuiFocus
|
|
width int
|
|
height int
|
|
status string
|
|
search string
|
|
searching bool
|
|
searchBeforeEdit string
|
|
jumping bool
|
|
showHelp bool
|
|
menuOpen bool
|
|
menuTitle string
|
|
menuContext tuiFocus
|
|
menuIndex int
|
|
menuOff int
|
|
menuItems []tuiMenuItem
|
|
menuFloating bool
|
|
menuRect tuiRect
|
|
quitRequested bool
|
|
showClosed bool
|
|
compactDetail bool
|
|
minSize int
|
|
memberSort tuiMemberSort
|
|
wideLayout tuiWideLayout
|
|
selected int
|
|
clusterOff int
|
|
memberRows []memberRow
|
|
memberOff int
|
|
memberIndex int
|
|
lastClickFocus tuiFocus
|
|
lastClickIndex int
|
|
lastClickX int
|
|
lastClickY int
|
|
lastClickAt time.Time
|
|
wheelScrollSeq int
|
|
wheelPending bool
|
|
wheelFocus tuiFocus
|
|
wheelDelta int
|
|
wheelSeq int
|
|
detailView viewport.Model
|
|
searchInput textinput.Model
|
|
detailCache map[int64]store.ClusterDetail
|
|
neighborCache map[int64][]tuiNeighbor
|
|
detail store.ClusterDetail
|
|
hasDetail bool
|
|
remoteRefreshing bool
|
|
remoteFrame int
|
|
}
|
|
|
|
type memberRow struct {
|
|
member store.ClusterMemberDetail
|
|
label string
|
|
selectable bool
|
|
}
|
|
|
|
type tuiMenuItem struct {
|
|
label string
|
|
action string
|
|
value string
|
|
}
|
|
|
|
const tuiMenuSeparatorAction = "separator"
|
|
const tuiDoubleClickWindow = 450 * time.Millisecond
|
|
|
|
const (
|
|
tuiOpenRowFG = "#f2c94c"
|
|
tuiOpenRowBG = "#14130f"
|
|
tuiOpenSelectedFG = "#f2c94c"
|
|
tuiOpenSelectedBG = "#1d1e18"
|
|
tuiOpenSelectedBlurFG = "#c3b66f"
|
|
tuiOpenSelectedBlurBG = "#171711"
|
|
tuiClosedRowFG = "#8793a3"
|
|
tuiClosedRowBG = "#0f141b"
|
|
tuiClosedSelectedFG = "#d6dde8"
|
|
tuiClosedSelectedBG = "#303744"
|
|
tuiClosedSelectedBlurFG = "#aab2bf"
|
|
tuiClosedSelectedBlurBG = "#242936"
|
|
tuiMutedAccent = "#8fb8d8"
|
|
)
|
|
|
|
func (item tuiMenuItem) selectable() bool {
|
|
return item.action != "" && item.action != tuiMenuSeparatorAction
|
|
}
|
|
|
|
func tuiMenuSection(label string) tuiMenuItem {
|
|
return tuiMenuItem{label: label, action: tuiMenuSeparatorAction}
|
|
}
|
|
|
|
func menuHasSection(items []tuiMenuItem, label string) bool {
|
|
for _, item := range items {
|
|
if item.action == tuiMenuSeparatorAction && item.label == label {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func actionMenuTitle(context tuiFocus) string {
|
|
switch context {
|
|
case focusClusters:
|
|
return "Cluster Actions"
|
|
case focusMembers:
|
|
return "Member Actions"
|
|
case focusDetail:
|
|
return "Detail Actions"
|
|
default:
|
|
return "Actions"
|
|
}
|
|
}
|
|
|
|
func actionMenuSubtitle(context tuiFocus) string {
|
|
switch context {
|
|
case focusClusters:
|
|
return "cluster scope"
|
|
case focusMembers:
|
|
return "selected member scope"
|
|
case focusDetail:
|
|
return "detail scope"
|
|
default:
|
|
return "current selection"
|
|
}
|
|
}
|
|
|
|
type actionMenuPalette struct {
|
|
accent string
|
|
background string
|
|
foreground string
|
|
selectedBG string
|
|
selectedFG string
|
|
}
|
|
|
|
func actionMenuColors(context tuiFocus) actionMenuPalette {
|
|
switch context {
|
|
case focusClusters:
|
|
return actionMenuPalette{
|
|
accent: "#8fb8d8",
|
|
background: "#111827",
|
|
foreground: "#d7dee8",
|
|
selectedBG: "#2f3f56",
|
|
selectedFG: "#f8fafc",
|
|
}
|
|
case focusMembers:
|
|
return actionMenuPalette{
|
|
accent: "#a8b8a0",
|
|
background: "#111a16",
|
|
foreground: "#d7dee8",
|
|
selectedBG: "#344337",
|
|
selectedFG: "#f8fafc",
|
|
}
|
|
default:
|
|
return actionMenuPalette{
|
|
accent: "#b8aa8f",
|
|
background: "#151922",
|
|
foreground: "#d7dee8",
|
|
selectedBG: "#3f3a31",
|
|
selectedFG: "#f8fafc",
|
|
}
|
|
}
|
|
}
|
|
|
|
type tuiNeighbor struct {
|
|
Thread store.Thread
|
|
Score float64
|
|
}
|
|
|
|
func (a *App) canRunInteractiveTUI() bool {
|
|
out, ok := a.Stdout.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return isatty.IsTerminal(out.Fd()) && isatty.IsTerminal(os.Stdin.Fd())
|
|
}
|
|
|
|
func (a *App) runInteractiveTUI(ctx context.Context, st *store.Store, repoID int64, payload clusterBrowserPayload) error {
|
|
out, ok := a.Stdout.(*os.File)
|
|
if !ok {
|
|
return a.writeOutput("tui", payload, true)
|
|
}
|
|
model := newClusterBrowserModel(ctx, st, repoID, payload)
|
|
program := tea.NewProgram(model, tea.WithInput(os.Stdin), tea.WithOutput(out), tea.WithAltScreen(), tea.WithMouseAllMotion())
|
|
finalModel, err := program.Run()
|
|
if final, ok := finalModel.(clusterBrowserModel); ok && final.store != nil && final.store != st {
|
|
_ = final.store.Close()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func newClusterBrowserModel(ctx context.Context, st *store.Store, repoID int64, payload clusterBrowserPayload) clusterBrowserModel {
|
|
clusters := append([]store.ClusterSummary(nil), payload.Clusters...)
|
|
payload.Clusters = clusters
|
|
search := textinput.New()
|
|
search.Prompt = "/ "
|
|
search.Placeholder = "filter clusters"
|
|
search.CharLimit = 80
|
|
search.Width = 40
|
|
model := clusterBrowserModel{
|
|
payload: payload,
|
|
allClusters: clusters,
|
|
ctx: ctx,
|
|
store: st,
|
|
repoID: repoID,
|
|
focus: focusClusters,
|
|
status: "Ready",
|
|
showClosed: !payload.HideClosed,
|
|
minSize: maxInt(1, payload.MinSize),
|
|
memberSort: memberSortKind,
|
|
wideLayout: wideLayoutColumns,
|
|
memberIndex: -1,
|
|
detailView: viewport.New(1, 1),
|
|
searchInput: search,
|
|
detailCache: map[int64]store.ClusterDetail{},
|
|
neighborCache: map[int64][]tuiNeighbor{},
|
|
}
|
|
if payload.DBSource == "remote" && payload.DBRefreshSource != "" && payload.DBRuntimePath != "" {
|
|
model.remoteRefreshing = true
|
|
model.status = "Refreshing remote data"
|
|
}
|
|
model.applyClusterFilters()
|
|
model.loadSelectedCluster()
|
|
return model
|
|
}
|
|
|
|
func (m clusterBrowserModel) Init() tea.Cmd {
|
|
cmds := []tea.Cmd{m.autoRefreshCmd()}
|
|
if m.remoteRefreshing {
|
|
cmds = append(cmds, m.remoteRefreshCmd(), m.remoteRefreshTickCmd())
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tuiAutoRefreshMsg:
|
|
if m.menuOpen || m.searching || m.jumping {
|
|
return m, m.autoRefreshCmd()
|
|
}
|
|
m.autoRefreshFromStore()
|
|
return m, m.autoRefreshCmd()
|
|
case tuiWheelScrollMsg:
|
|
if msg.seq != m.wheelScrollSeq {
|
|
return m, nil
|
|
}
|
|
cmd := m.applyQueuedWheelScroll()
|
|
m.keepVisible()
|
|
m.syncComponents()
|
|
return m, cmd
|
|
case tuiWheelSettledMsg:
|
|
if msg.seq != m.wheelSeq {
|
|
return m, nil
|
|
}
|
|
m.loadSelectedCluster()
|
|
m.keepVisible()
|
|
m.syncComponents()
|
|
return m, nil
|
|
case tuiRemoteRefreshTickMsg:
|
|
if !m.remoteRefreshing {
|
|
return m, nil
|
|
}
|
|
m.remoteFrame++
|
|
return m, m.remoteRefreshTickCmd()
|
|
case tuiRemoteRefreshMsg:
|
|
m.remoteRefreshing = false
|
|
if msg.err != nil {
|
|
m.status = "Remote refresh failed: " + msg.err.Error()
|
|
return m, nil
|
|
}
|
|
if msg.changed {
|
|
if err := m.reopenRuntimeStore(); err != nil {
|
|
m.status = "Remote refresh loaded but reopen failed: " + err.Error()
|
|
return m, nil
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = "Remote data refreshed"
|
|
return m, nil
|
|
}
|
|
m.status = "Remote data already current"
|
|
return m, nil
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.syncComponents()
|
|
m.keepVisible()
|
|
case tea.KeyMsg:
|
|
m.cancelQueuedWheelScroll()
|
|
if m.menuOpen {
|
|
return m.updateMenu(msg)
|
|
}
|
|
if m.searching {
|
|
var cmd tea.Cmd
|
|
m, cmd = m.handleSearchKey(msg)
|
|
m.keepVisible()
|
|
return m, cmd
|
|
}
|
|
if m.jumping {
|
|
var cmd tea.Cmd
|
|
m, cmd = m.handleJumpKey(msg)
|
|
m.keepVisible()
|
|
return m, cmd
|
|
}
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "tab", "right":
|
|
m.focus = nextFocus(m.focus, 1)
|
|
case "shift+tab", "left":
|
|
m.focus = nextFocus(m.focus, -1)
|
|
case "up", "k":
|
|
m.move(-1)
|
|
case "down", "j":
|
|
m.move(1)
|
|
case "pgup", "ctrl+b":
|
|
m.move(-m.pageStep())
|
|
case "pgdown", "ctrl+f":
|
|
m.move(m.pageStep())
|
|
case "home", "g":
|
|
m.jumpEdge(false)
|
|
case "end", "G":
|
|
m.jumpEdge(true)
|
|
case "enter":
|
|
if m.focus == focusClusters {
|
|
m.focus = focusMembers
|
|
} else if m.focus == focusMembers {
|
|
m.loadSelectedThreadNeighbors(10, 0.2)
|
|
if m.focus != focusDetail {
|
|
m.focus = focusDetail
|
|
}
|
|
}
|
|
case "o":
|
|
m.runAction("open")
|
|
case "c":
|
|
m.runAction("copy-url")
|
|
case "a":
|
|
m.clearMenuPlacement()
|
|
m.openActionMenu()
|
|
case "s":
|
|
if m.payload.Sort == "recent" {
|
|
m.payload.Sort = "size"
|
|
} else {
|
|
m.payload.Sort = "recent"
|
|
}
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: " + m.payload.Sort
|
|
case "m":
|
|
m.memberSort = nextMemberSort(m.memberSort)
|
|
m.sortMembers()
|
|
m.status = "Member sort: " + string(m.memberSort)
|
|
case "n":
|
|
m.loadSelectedThreadNeighbors(10, 0.2)
|
|
case "d":
|
|
m.toggleDetailMode()
|
|
case "l":
|
|
m.toggleWideLayout()
|
|
case "p":
|
|
m.openRepositoryMenu()
|
|
case "r":
|
|
m.refreshFromStore()
|
|
case "f":
|
|
m.minSize = nextMinSize(m.minSize)
|
|
m.applyClusterFilters()
|
|
m.status = fmt.Sprintf("Min size: %s", minSizeLabel(m.minSize))
|
|
case "x":
|
|
m.toggleClosedVisibility()
|
|
case "/":
|
|
cmd := m.startFilterInput()
|
|
return m, cmd
|
|
case "#":
|
|
cmd := m.startJumpInput()
|
|
return m, cmd
|
|
case "esc":
|
|
if m.showHelp {
|
|
m.showHelp = false
|
|
}
|
|
case "h", "?":
|
|
m.showHelp = !m.showHelp
|
|
if m.showHelp {
|
|
m.status = "Help"
|
|
} else {
|
|
m.status = "Ready"
|
|
}
|
|
}
|
|
m.keepVisible()
|
|
m.syncComponents()
|
|
case tea.MouseMsg:
|
|
cmd := m.handleMouse(msg)
|
|
if m.quitRequested {
|
|
return m, tea.Quit
|
|
}
|
|
m.keepVisible()
|
|
m.syncComponents()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m clusterBrowserModel) remoteRefreshCmd() tea.Cmd {
|
|
sourceDBPath := m.payload.DBRefreshSource
|
|
runtimeDBPath := m.payload.DBRuntimePath
|
|
return func() tea.Msg {
|
|
changed, err := refreshPortableRuntimeDB(m.ctx, sourceDBPath, runtimeDBPath, true)
|
|
return tuiRemoteRefreshMsg{changed: changed, err: err}
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) remoteRefreshTickCmd() tea.Cmd {
|
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg {
|
|
return tuiRemoteRefreshTickMsg{}
|
|
})
|
|
}
|
|
|
|
func (m *clusterBrowserModel) reopenRuntimeStore() error {
|
|
if strings.TrimSpace(m.payload.DBRuntimePath) == "" {
|
|
return nil
|
|
}
|
|
next, err := store.OpenReadOnly(m.ctx, m.payload.DBRuntimePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if m.store != nil {
|
|
_ = m.store.Close()
|
|
}
|
|
m.store = next
|
|
m.detailCache = map[int64]store.ClusterDetail{}
|
|
m.neighborCache = map[int64][]tuiNeighbor{}
|
|
return nil
|
|
}
|
|
|
|
func (m clusterBrowserModel) View() string {
|
|
if m.width <= 0 || m.height <= 0 {
|
|
return "loading gitcrawl tui..."
|
|
}
|
|
layout := m.layout()
|
|
m.syncComponents()
|
|
header := m.renderHeader(layout.header.w)
|
|
clusters := m.renderClusters(layout.clusters)
|
|
members := m.renderMembers(layout.members)
|
|
detail := m.renderDetail(layout.detail)
|
|
footer := m.renderFooter(layout.footer.w)
|
|
body := lipgloss.JoinHorizontal(lipgloss.Top, clusters, members, detail)
|
|
if !layout.stacked && layout.detail.y > layout.members.y {
|
|
body = lipgloss.JoinHorizontal(lipgloss.Top, clusters, lipgloss.JoinVertical(lipgloss.Left, members, detail))
|
|
}
|
|
if layout.stacked {
|
|
if layout.members.x == 0 {
|
|
body = lipgloss.JoinVertical(lipgloss.Left, clusters, members, detail)
|
|
} else {
|
|
top := lipgloss.JoinHorizontal(lipgloss.Top, clusters, members)
|
|
body = lipgloss.JoinVertical(lipgloss.Left, top, detail)
|
|
}
|
|
}
|
|
body = fitBlock(body, layout.header.w, maxInt(1, layout.footer.y-layout.header.h))
|
|
view := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
|
if m.menuOpen && m.menuFloating {
|
|
view = m.renderFloatingMenu(view)
|
|
}
|
|
return fitBlock(view, layout.header.w, m.height)
|
|
}
|
|
|
|
type tuiLayout struct {
|
|
header tuiRect
|
|
clusters tuiRect
|
|
members tuiRect
|
|
detail tuiRect
|
|
footer tuiRect
|
|
stacked bool
|
|
mode string
|
|
}
|
|
|
|
func (m clusterBrowserModel) layout() tuiLayout {
|
|
width := maxInt(m.width, 80)
|
|
height := maxInt(m.height, 24)
|
|
headerH := 1
|
|
footerH := 2
|
|
bodyH := maxInt(8, height-headerH-footerH)
|
|
layout := tuiLayout{
|
|
header: tuiRect{x: 0, y: 0, w: width, h: headerH},
|
|
footer: tuiRect{x: 0, y: headerH + bodyH, w: width, h: footerH},
|
|
}
|
|
if width >= 140 {
|
|
if m.wideLayout == wideLayoutRightStack {
|
|
clusterW := maxInt(56, width*44/100)
|
|
rightW := width - clusterW
|
|
memberH := maxInt(8, bodyH*42/100)
|
|
layout.mode = string(wideLayoutRightStack)
|
|
layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: bodyH}
|
|
layout.members = tuiRect{x: clusterW, y: headerH, w: rightW, h: memberH}
|
|
layout.detail = tuiRect{x: clusterW, y: headerH + memberH, w: rightW, h: bodyH - memberH}
|
|
return layout
|
|
}
|
|
clusterW := maxInt(48, width*34/100)
|
|
memberW := maxInt(40, width*30/100)
|
|
detailW := maxInt(42, width-clusterW-memberW)
|
|
layout.mode = string(wideLayoutColumns)
|
|
layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: bodyH}
|
|
layout.members = tuiRect{x: clusterW, y: headerH, w: memberW, h: bodyH}
|
|
layout.detail = tuiRect{x: clusterW + memberW, y: headerH, w: detailW, h: bodyH}
|
|
return layout
|
|
}
|
|
if width < 100 {
|
|
layout.stacked = true
|
|
layout.mode = "stacked"
|
|
clusterH := maxInt(7, bodyH*36/100)
|
|
memberH := maxInt(6, bodyH*28/100)
|
|
detailH := maxInt(6, bodyH-clusterH-memberH)
|
|
layout.clusters = tuiRect{x: 0, y: headerH, w: width, h: clusterH}
|
|
layout.members = tuiRect{x: 0, y: headerH + clusterH, w: width, h: memberH}
|
|
layout.detail = tuiRect{x: 0, y: headerH + clusterH + memberH, w: width, h: detailH}
|
|
return layout
|
|
}
|
|
layout.stacked = true
|
|
layout.mode = "split"
|
|
topH := maxInt(8, bodyH/2)
|
|
bottomH := bodyH - topH
|
|
clusterW := width / 2
|
|
layout.clusters = tuiRect{x: 0, y: headerH, w: clusterW, h: topH}
|
|
layout.members = tuiRect{x: clusterW, y: headerH, w: width - clusterW, h: topH}
|
|
layout.detail = tuiRect{x: 0, y: headerH + topH, w: width, h: bottomH}
|
|
return layout
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderHeader(width int) string {
|
|
openCounts := m.openCounts()
|
|
line := fmt.Sprintf("%s %d PR %d issues clusters:%d sort:%s members:%s min:%s layout:%s detail:%s closed:%s filter:%s",
|
|
m.payload.Repository,
|
|
openCounts.pulls,
|
|
openCounts.issues,
|
|
len(m.payload.Clusters),
|
|
m.payload.Sort,
|
|
m.memberSort,
|
|
minSizeLabel(m.minSize),
|
|
layoutLabel(m.layout()),
|
|
detailModeLabel(m.compactDetail),
|
|
boolLabel(m.showClosed),
|
|
firstNonEmpty(m.search, "none"),
|
|
)
|
|
if m.payload.InferredRepository {
|
|
line += " inferred"
|
|
}
|
|
content := padCells(" "+truncateCells(line, maxInt(1, width-2)), width)
|
|
style := lipgloss.NewStyle().Width(width).Height(1).Background(lipgloss.Color("#0d1321")).Foreground(lipgloss.Color("#f7f7ff")).Bold(true)
|
|
return style.Render(content)
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderFooter(width int) string {
|
|
controls := footerControls(width)
|
|
line := firstNonEmpty(m.status, "Ready")
|
|
if m.searching {
|
|
line = "Filter: " + m.searchInput.View()
|
|
}
|
|
if m.jumping {
|
|
line = "Jump: " + m.searchInput.View()
|
|
}
|
|
if m.remoteRefreshing {
|
|
line = fmt.Sprintf("Refreshing remote data %s %s", loadingFrame(m.remoteFrame), line)
|
|
}
|
|
if location := m.footerLocation(); location != "" {
|
|
line = strings.TrimSpace(line + " " + location)
|
|
}
|
|
bg, fg := footerPalette(m.payload.DBSource)
|
|
statusLine := padCells(" "+truncateCells(line, maxInt(1, width-2)), width)
|
|
controlsLine := padCells(" "+truncateCells(controls, maxInt(1, width-2)), width)
|
|
return lipgloss.NewStyle().Width(width).Height(2).Background(bg).Foreground(fg).Render(statusLine + "\n" + controlsLine)
|
|
}
|
|
|
|
func footerControls(width int) string {
|
|
full := "Tab focus click select right-click menu a actions header sort wheel scroll / filter # jump p repos n neighbors s sort m members d detail r refresh f min l layout x closed ? help q quit"
|
|
if lipgloss.Width(full) <= maxInt(1, width-2) {
|
|
return full
|
|
}
|
|
compact := "Tab focus click select right-click menu a actions wheel scroll / filter # jump r refresh ? help q quit"
|
|
if lipgloss.Width(compact) <= maxInt(1, width-2) {
|
|
return compact
|
|
}
|
|
return "Tab focus click right-click menu a actions / filter # jump ? help q quit"
|
|
}
|
|
|
|
func loadingFrame(index int) string {
|
|
frames := []string{"-", "\\", "|", "/"}
|
|
return frames[index%len(frames)]
|
|
}
|
|
|
|
func (m clusterBrowserModel) footerLocation() string {
|
|
location := strings.TrimSpace(m.payload.DBLocation)
|
|
if location == "" {
|
|
return ""
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(m.payload.DBSource)) {
|
|
case "remote":
|
|
return "remote " + location
|
|
case "local":
|
|
return "local " + location
|
|
default:
|
|
return location
|
|
}
|
|
}
|
|
|
|
func footerPalette(source string) (lipgloss.Color, lipgloss.Color) {
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case "remote":
|
|
return lipgloss.Color("#f2c14e"), lipgloss.Color("#05070d")
|
|
default:
|
|
return lipgloss.Color("#5bc0eb"), lipgloss.Color("#05070d")
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderClusters(rect tuiRect) string {
|
|
tableWidth := tableViewportWidth(rect)
|
|
tableView := renderStyledTable(clusterColumns(tableWidth, m.payload.Sort), m.clusterRows(), m.clusterOff, tableViewportHeight(rect), tableWidth, "#5bc0eb", func(index int) lipgloss.Style {
|
|
if index < 0 || index >= len(m.payload.Clusters) {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef"))
|
|
}
|
|
return clusterRowStyle(m.payload.Clusters[index], index == m.selected, m.focus == focusClusters)
|
|
})
|
|
return paneStyle(focusClusters, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusClusters, m.focus, m.clusterPositionLabel()), tableView))
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderMembers(rect tuiRect) string {
|
|
tableWidth := tableViewportWidth(rect)
|
|
tableView := renderStyledTable(memberColumns(tableWidth, m.memberSort), m.memberTableRows(), m.memberOff, tableViewportHeight(rect), tableWidth, "#9bc53d", func(index int) lipgloss.Style {
|
|
if index < 0 || index >= len(m.memberRows) {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#dfe7ef"))
|
|
}
|
|
return memberRowStyle(m.memberRows[index], index == m.memberIndex, m.focus == focusMembers)
|
|
})
|
|
return paneStyle(focusMembers, m.focus, rect.w, rect.h).Render(lipgloss.JoinVertical(lipgloss.Left, paneTitle(focusMembers, m.focus, m.memberPositionLabel()), tableView))
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderDetail(rect tuiRect) string {
|
|
mode := "full"
|
|
if m.compactDetail {
|
|
mode = "compact"
|
|
}
|
|
lines := append([]string{paneTitle(focusDetail, m.focus, mode)}, m.detailLines(rect.w-4)...)
|
|
if m.showHelp {
|
|
lines = append([]string{paneTitle(focusDetail, m.focus, mode)}, m.helpLines(rect.w-4)...)
|
|
}
|
|
if m.menuOpen && !m.menuFloating {
|
|
lines = append([]string{paneTitle(focusDetail, m.focus, mode)}, m.menuLines(rect.w-4)...)
|
|
}
|
|
m.detailView.SetContent(strings.Join(lines, "\n"))
|
|
return paneStyle(focusDetail, m.focus, rect.w, rect.h).Render(m.detailView.View())
|
|
}
|
|
|
|
func (m clusterBrowserModel) detailLines(width int) []string {
|
|
if len(m.payload.Clusters) == 0 {
|
|
return []string{
|
|
bold("No clusters visible"),
|
|
"",
|
|
"No clusters match the current view.",
|
|
"",
|
|
"Try f to lower the minimum size, / to clear the filter, x to show closed clusters, or r to refresh from the local store.",
|
|
"",
|
|
"If the store is empty, run sync, refresh summaries/embeddings, and cluster first.",
|
|
}
|
|
}
|
|
cluster := m.payload.Clusters[m.selected]
|
|
lines := []string{
|
|
bold(fmt.Sprintf("Cluster %d", cluster.ID)),
|
|
color("#5bc0eb", cluster.StableSlug),
|
|
}
|
|
lines = append(lines, wrapPlain(splitClusterTitle(cluster), width)...)
|
|
lines = append(lines,
|
|
"",
|
|
fmt.Sprintf("members: %d status: %s updated: %s", cluster.MemberCount, firstNonEmpty(cluster.Status, "unknown"), formatRelativeTime(cluster.UpdatedAt)),
|
|
fmt.Sprintf("representative: %s", threadRef(cluster)),
|
|
"",
|
|
)
|
|
if !m.hasDetail {
|
|
lines = append(lines, "Cluster details unavailable.", m.status)
|
|
return lines
|
|
}
|
|
member, ok := m.selectedMember()
|
|
if !ok {
|
|
lines = append(lines, "Select a cluster to inspect members.")
|
|
return lines
|
|
}
|
|
thread := member.Thread
|
|
lines = append(lines,
|
|
dim(tuiRule(width)),
|
|
bold(fmt.Sprintf("%s #%d", kindTitle(thread.Kind), thread.Number)),
|
|
)
|
|
lines = append(lines, wrapPlain(renderTitleText(thread.Title), width)...)
|
|
lines = append(lines,
|
|
"",
|
|
)
|
|
lines = append(lines, wrapPlain(fmt.Sprintf("closed: %s", closedLabel(thread)), width)...)
|
|
lines = append(lines, wrapPlain(fmt.Sprintf("updated: %s author: %s", formatRelativeTime(thread.UpdatedAtGitHub), firstNonEmpty(thread.AuthorLogin, "unknown")), width)...)
|
|
if labels := labelsFromJSON(thread.LabelsJSON); labels != "" {
|
|
lines = append(lines, wrapPlain("labels: "+labels, width)...)
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, wrapPlain(fmt.Sprintf("url: %s", thread.HTMLURL), width)...)
|
|
lines = append(lines, "")
|
|
if neighbors, ok := m.neighborCache[thread.ID]; ok {
|
|
lines = append(lines, dim(tuiRule(width)))
|
|
lines = append(lines, bold("Neighbors"))
|
|
if len(neighbors) == 0 {
|
|
lines = append(lines, "No neighbors above threshold.", "")
|
|
} else {
|
|
for _, neighbor := range neighbors {
|
|
lines = append(lines, truncateCells(fmt.Sprintf("#%d %s %.1f%% %s",
|
|
neighbor.Thread.Number,
|
|
kindTitle(neighbor.Thread.Kind),
|
|
neighbor.Score*100,
|
|
renderTitleText(neighbor.Thread.Title),
|
|
), width))
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
if len(member.Summaries) > 0 {
|
|
lines = append(lines, dim(tuiRule(width)))
|
|
lines = append(lines, bold("LLM Summary"))
|
|
for _, key := range sortedSummaryKeys(member.Summaries) {
|
|
lines = append(lines, dim(formatSummaryLabel(key)+":"))
|
|
lines = append(lines, markdownLines(member.Summaries[key], width)...)
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
if strings.TrimSpace(member.BodySnippet) != "" {
|
|
lines = append(lines, dim(tuiRule(width)))
|
|
lines = append(lines, bold("Main Preview"))
|
|
lines = appendLimitedLines(lines, markdownLines(member.BodySnippet, width), m.detailBodyLimit())
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m clusterBrowserModel) helpLines(width int) []string {
|
|
lines := []string{
|
|
bold("Gitcrawl TUI"),
|
|
"",
|
|
"Mouse",
|
|
" left click: focus/select a pane row",
|
|
" left click menu row: run that action",
|
|
" wheel: scroll the pane under the pointer",
|
|
" wheel in menu: move the highlighted action",
|
|
" right click: open a stable action menu",
|
|
" menu actions: copy, links, neighbors, member triage, local close/reopen, repos, filter, jump, sort, refresh, layout, quit",
|
|
"",
|
|
"Keyboard",
|
|
" Tab / Shift-Tab: cycle focus",
|
|
" arrows or j/k: move selection or scroll detail",
|
|
" PageUp/PageDown: page the active pane",
|
|
" Enter: drill into the next pane, loading neighbors from members",
|
|
" a: open action menu",
|
|
" /: filter clusters",
|
|
" #: jump to issue/PR number",
|
|
" s: toggle cluster sort",
|
|
" m: cycle member sort",
|
|
" n: load neighbors for selected thread",
|
|
" d: toggle compact/full detail",
|
|
" r: refresh from local store",
|
|
" p: switch repository",
|
|
" l: toggle wide layout",
|
|
" f: cycle minimum cluster size",
|
|
" x: show/hide closed clusters",
|
|
" o: open selected thread or representative",
|
|
" c: copy selected thread or representative URL",
|
|
" auto-refresh: local store changes are picked up every 15s",
|
|
" Enter in menu: run action or open link picker",
|
|
" b in submenu: back to actions",
|
|
" ?: toggle this help",
|
|
" q: quit",
|
|
}
|
|
out := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, " ") {
|
|
out = append(out, line)
|
|
continue
|
|
}
|
|
out = append(out, wrapPlain(line, width)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m clusterBrowserModel) menuLines(width int) []string {
|
|
palette := actionMenuColors(m.menuContext)
|
|
title := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color(palette.accent)).
|
|
Render(firstNonEmpty(m.menuTitle, "Actions"))
|
|
lines := []string{title, dim(actionMenuSubtitle(m.menuContext)), ""}
|
|
visible := m.menuVisibleCount()
|
|
start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible))
|
|
end := minInt(len(m.menuItems), start+visible)
|
|
shortcut := 0
|
|
for index := start; index < end; index++ {
|
|
item := m.menuItems[index]
|
|
if !item.selectable() {
|
|
lines = append(lines, truncateCells(" "+dim(item.label), width))
|
|
continue
|
|
}
|
|
shortcut++
|
|
prefix := " "
|
|
if index == m.menuIndex {
|
|
prefix = "> "
|
|
}
|
|
key := " "
|
|
if shortcut <= 9 {
|
|
key = fmt.Sprintf("%d. ", shortcut)
|
|
}
|
|
line := truncateCells(prefix+key+item.label, width)
|
|
if index == m.menuIndex {
|
|
line = selectedMenuLineStyle(width, palette).Render(padCells(line, width))
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
footer := "Enter/1-9 run Esc close"
|
|
if m.inMenuSubmenu() {
|
|
footer = "Enter/1-9 run b back Esc close"
|
|
}
|
|
if len(m.menuItems) > visible {
|
|
if m.inMenuSubmenu() {
|
|
footer = fmt.Sprintf("Enter/1-9 run b back Esc close Pg page %d-%d/%d", start+1, end, len(m.menuItems))
|
|
} else {
|
|
footer = fmt.Sprintf("Enter/1-9 run Esc close Pg page %d-%d/%d", start+1, end, len(m.menuItems))
|
|
}
|
|
}
|
|
lines = append(lines, "", dim(footer))
|
|
return lines
|
|
}
|
|
|
|
func (m clusterBrowserModel) renderFloatingMenu(view string) string {
|
|
rect := m.menuRect
|
|
if rect.w <= 0 || rect.h <= 0 {
|
|
return view
|
|
}
|
|
lines := m.menuLines(maxInt(1, rect.w-2))
|
|
if len(lines) > maxInt(0, rect.h-2) {
|
|
lines = lines[:maxInt(0, rect.h-2)]
|
|
}
|
|
box := floatingMenuStyle(rect.w, rect.h, actionMenuColors(m.menuContext)).Render(strings.Join(lines, "\n"))
|
|
return overlayBlock(view, box, rect.x, rect.y, m.width)
|
|
}
|
|
|
|
func (m clusterBrowserModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
page := maxInt(1, m.menuVisibleCount())
|
|
if index, ok := visibleMenuShortcutIndex(msg.String(), m.menuItems, m.menuOff, page); ok {
|
|
m.menuIndex = index
|
|
if m.runMenuItem(m.menuItems[m.menuIndex]) {
|
|
m.closeMenu("")
|
|
}
|
|
if m.quitRequested {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
switch msg.String() {
|
|
case "esc", "q":
|
|
m.closeMenu("Menu closed")
|
|
case "h", "?":
|
|
m.closeMenu("")
|
|
m.showHelp = true
|
|
m.status = "Help"
|
|
case "b", "left", "backspace":
|
|
if m.inMenuSubmenu() {
|
|
m.openActionMenuFor(m.menuContext)
|
|
}
|
|
case "a":
|
|
if m.inMenuSubmenu() {
|
|
m.openActionMenuFor(m.menuContext)
|
|
}
|
|
case "/":
|
|
cmd := m.startFilterInput()
|
|
return m, cmd
|
|
case "#":
|
|
cmd := m.startJumpInput()
|
|
return m, cmd
|
|
case "p":
|
|
m.openRepositoryMenu()
|
|
case "n":
|
|
m.closeMenu("")
|
|
m.loadSelectedThreadNeighbors(10, 0.2)
|
|
case "r":
|
|
m.closeMenu("")
|
|
m.refreshFromStore()
|
|
case "l":
|
|
m.closeMenu("")
|
|
m.toggleWideLayout()
|
|
case "d":
|
|
m.closeMenu("")
|
|
m.toggleDetailMode()
|
|
case "s":
|
|
m.closeMenu("")
|
|
if m.payload.Sort == "recent" {
|
|
m.payload.Sort = "size"
|
|
} else {
|
|
m.payload.Sort = "recent"
|
|
}
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: " + m.payload.Sort
|
|
case "m":
|
|
m.closeMenu("")
|
|
m.memberSort = nextMemberSort(m.memberSort)
|
|
m.sortMembers()
|
|
m.status = "Member sort: " + string(m.memberSort)
|
|
case "up", "k":
|
|
m.menuIndex = m.nextSelectableMenuIndex(-1)
|
|
m.keepMenuVisible()
|
|
case "down", "j":
|
|
m.menuIndex = m.nextSelectableMenuIndex(1)
|
|
m.keepMenuVisible()
|
|
case "pgup", "ctrl+b":
|
|
m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex-page, -1)
|
|
m.keepMenuVisible()
|
|
case "pgdown", "ctrl+f":
|
|
m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex+page, 1)
|
|
m.keepMenuVisible()
|
|
case "home", "g":
|
|
m.menuIndex = m.firstSelectableMenuIndex()
|
|
m.keepMenuVisible()
|
|
case "end", "G":
|
|
m.menuIndex = m.lastSelectableMenuIndex()
|
|
m.keepMenuVisible()
|
|
case "enter":
|
|
if m.menuIndex >= 0 && m.menuIndex < len(m.menuItems) {
|
|
if m.runMenuItem(m.menuItems[m.menuIndex]) {
|
|
m.closeMenu("")
|
|
}
|
|
if m.quitRequested {
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) move(delta int) {
|
|
if m.focus == focusDetail {
|
|
if delta > 0 {
|
|
m.detailView.LineDown(delta)
|
|
} else {
|
|
m.detailView.LineUp(-delta)
|
|
}
|
|
return
|
|
}
|
|
if m.focus == focusMembers {
|
|
if len(m.memberRows) == 0 {
|
|
return
|
|
}
|
|
previous := m.memberIndex
|
|
m.memberIndex = m.nextSelectableMemberIndex(m.memberIndex, delta)
|
|
if m.memberIndex != previous {
|
|
m.detailView.GotoTop()
|
|
}
|
|
if thread, ok := m.selectedThread(); ok {
|
|
m.status = fmt.Sprintf("Selected #%d", thread.Number)
|
|
}
|
|
return
|
|
}
|
|
if len(m.payload.Clusters) == 0 {
|
|
return
|
|
}
|
|
m.selected = clampInt(m.selected+delta, 0, len(m.payload.Clusters)-1)
|
|
m.loadSelectedCluster()
|
|
m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID)
|
|
}
|
|
|
|
func (m clusterBrowserModel) handleSearchKey(msg tea.KeyMsg) (clusterBrowserModel, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "enter":
|
|
m.searching = false
|
|
m.search = m.searchInput.Value()
|
|
m.searchInput.Blur()
|
|
m.applyClusterFilters()
|
|
if m.search == "" {
|
|
m.status = "Filter cleared"
|
|
} else {
|
|
m.status = "Filter: " + m.search
|
|
}
|
|
case "esc":
|
|
m.searching = false
|
|
m.search = m.searchBeforeEdit
|
|
m.searchInput.Blur()
|
|
m.applyClusterFilters()
|
|
m.status = "Filter cancelled"
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
m.search = m.searchInput.Value()
|
|
m.applyClusterFilters()
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) startFilterInput() tea.Cmd {
|
|
m.searching = true
|
|
m.searchBeforeEdit = m.search
|
|
m.jumping = false
|
|
m.showHelp = false
|
|
m.closeMenu("")
|
|
m.searchInput.Prompt = "/ "
|
|
m.searchInput.Placeholder = "filter clusters"
|
|
m.searchInput.SetValue(m.search)
|
|
m.status = "Filter: " + m.search
|
|
return m.searchInput.Focus()
|
|
}
|
|
|
|
func (m *clusterBrowserModel) startJumpInput() tea.Cmd {
|
|
m.jumping = true
|
|
m.searching = false
|
|
m.showHelp = false
|
|
m.closeMenu("")
|
|
m.searchInput.Prompt = "# "
|
|
m.searchInput.Placeholder = "issue, PR, or GitHub URL"
|
|
m.searchInput.SetValue("")
|
|
m.status = "Jump to issue/PR"
|
|
return m.searchInput.Focus()
|
|
}
|
|
|
|
func (m clusterBrowserModel) handleJumpKey(msg tea.KeyMsg) (clusterBrowserModel, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "enter":
|
|
m.jumping = false
|
|
value := strings.TrimSpace(m.searchInput.Value())
|
|
m.searchInput.Blur()
|
|
number, err := parseOptionalThreadNumber(value)
|
|
if err != nil || number <= 0 {
|
|
m.status = "Enter a positive issue or PR number"
|
|
return m, nil
|
|
}
|
|
m.jumpToThreadNumber(number)
|
|
case "esc":
|
|
m.jumping = false
|
|
m.searchInput.Blur()
|
|
m.status = "Jump cancelled"
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd {
|
|
layout := m.layout()
|
|
if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonNone {
|
|
if m.menuOpen {
|
|
m.handleMenuMouse(layout, msg)
|
|
}
|
|
return nil
|
|
}
|
|
if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) {
|
|
return nil
|
|
}
|
|
if !isMouseWheel(msg.Button) {
|
|
m.cancelQueuedWheelScroll()
|
|
}
|
|
if m.menuOpen {
|
|
m.handleMenuMouse(layout, msg)
|
|
return nil
|
|
}
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
return m.mouseWheel(layout, msg, -3)
|
|
case tea.MouseButtonWheelDown:
|
|
return m.mouseWheel(layout, msg, 3)
|
|
case tea.MouseButtonLeft:
|
|
if msg.Action != tea.MouseActionPress {
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
switch {
|
|
case layout.clusters.contains(msg.X, msg.Y):
|
|
m.focus = focusClusters
|
|
row := msg.Y - layout.clusters.y - 3
|
|
if row == -1 {
|
|
m.sortClustersFromHeader(msg.X - layout.clusters.x - 2)
|
|
return nil
|
|
}
|
|
if row < 0 {
|
|
return nil
|
|
}
|
|
index := m.clusterOff + row
|
|
if index >= 0 && index < len(m.payload.Clusters) {
|
|
m.selected = index
|
|
m.loadSelectedCluster()
|
|
m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID)
|
|
m.finishRowClick(focusClusters, index, msg.X, msg.Y, now)
|
|
}
|
|
case layout.members.contains(msg.X, msg.Y):
|
|
m.focus = focusMembers
|
|
row := msg.Y - layout.members.y - 3
|
|
if row == -1 {
|
|
m.sortMembersFromHeader(msg.X - layout.members.x - 2)
|
|
return nil
|
|
}
|
|
if row < 0 {
|
|
return nil
|
|
}
|
|
index := m.memberOff + row
|
|
if index >= 0 && index < len(m.memberRows) {
|
|
if !m.memberRows[index].selectable {
|
|
m.memberIndex = index
|
|
m.status = m.memberRows[index].label
|
|
m.clearLastClick()
|
|
return nil
|
|
}
|
|
previous := m.memberIndex
|
|
m.memberIndex = index
|
|
if m.memberIndex != previous {
|
|
m.detailView.GotoTop()
|
|
}
|
|
m.status = fmt.Sprintf("Selected #%d", m.memberRows[m.memberIndex].thread().Number)
|
|
m.finishRowClick(focusMembers, index, msg.X, msg.Y, now)
|
|
}
|
|
case layout.detail.contains(msg.X, msg.Y):
|
|
m.focus = focusDetail
|
|
}
|
|
case tea.MouseButtonRight:
|
|
if msg.Action != tea.MouseActionPress {
|
|
return nil
|
|
}
|
|
context := m.actionMenuContextAt(layout, msg.X, msg.Y)
|
|
m.selectByMousePosition(layout, msg.X, msg.Y)
|
|
if context == focusMembers {
|
|
if _, ok := m.selectedMember(); !ok {
|
|
context = focusClusters
|
|
}
|
|
}
|
|
m.openActionMenuFor(context)
|
|
m.placeFloatingMenu(layout, msg.X, msg.Y)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) finishRowClick(focus tuiFocus, index, x, y int, now time.Time) {
|
|
if m.isDoubleClick(focus, index, x, y, now) {
|
|
m.clearLastClick()
|
|
m.runAction("open")
|
|
return
|
|
}
|
|
m.lastClickFocus = focus
|
|
m.lastClickIndex = index
|
|
m.lastClickX = x
|
|
m.lastClickY = y
|
|
m.lastClickAt = now
|
|
}
|
|
|
|
func (m *clusterBrowserModel) isDoubleClick(focus tuiFocus, index, x, y int, now time.Time) bool {
|
|
return !m.lastClickAt.IsZero() &&
|
|
m.lastClickFocus == focus &&
|
|
m.lastClickIndex == index &&
|
|
m.lastClickX == x &&
|
|
m.lastClickY == y &&
|
|
now.Sub(m.lastClickAt) <= tuiDoubleClickWindow
|
|
}
|
|
|
|
func (m *clusterBrowserModel) clearLastClick() {
|
|
m.lastClickAt = time.Time{}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) handleMenuMouse(layout tuiLayout, msg tea.MouseMsg) {
|
|
if msg.Action == tea.MouseActionMotion {
|
|
index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y)
|
|
if !ok {
|
|
return
|
|
}
|
|
if index < 0 || index >= len(m.menuItems) {
|
|
return
|
|
}
|
|
if !m.menuItems[index].selectable() {
|
|
index = m.nearestSelectableMenuIndex(index, 1)
|
|
}
|
|
if index >= 0 && index < len(m.menuItems) && m.menuItems[index].selectable() {
|
|
m.menuIndex = index
|
|
m.keepMenuVisible()
|
|
}
|
|
return
|
|
}
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
m.menuIndex = m.nextSelectableMenuIndex(-1)
|
|
m.keepMenuVisible()
|
|
return
|
|
case tea.MouseButtonWheelDown:
|
|
m.menuIndex = m.nextSelectableMenuIndex(1)
|
|
m.keepMenuVisible()
|
|
return
|
|
case tea.MouseButtonRight:
|
|
if msg.Action == tea.MouseActionPress {
|
|
m.closeMenu("Menu closed")
|
|
}
|
|
return
|
|
}
|
|
if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
|
|
return
|
|
}
|
|
index, ok := m.menuIndexAtMouse(layout, msg.X, msg.Y)
|
|
if !ok {
|
|
m.closeMenu("Menu closed")
|
|
return
|
|
}
|
|
if index < 0 || index >= len(m.menuItems) {
|
|
return
|
|
}
|
|
if !m.menuItems[index].selectable() {
|
|
m.menuIndex = m.nearestSelectableMenuIndex(index, 1)
|
|
m.keepMenuVisible()
|
|
return
|
|
}
|
|
m.menuIndex = index
|
|
m.keepMenuVisible()
|
|
if m.runMenuItem(m.menuItems[m.menuIndex]) {
|
|
m.closeMenu("")
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) menuIndexAtMouse(layout tuiLayout, x, y int) (int, bool) {
|
|
menuRect := layout.detail
|
|
rowOffset := 4
|
|
if m.menuFloating {
|
|
menuRect = m.menuRect
|
|
rowOffset = 3
|
|
}
|
|
if !menuRect.contains(x, y) {
|
|
return 0, false
|
|
}
|
|
return m.menuOff + y - menuRect.y - rowOffset, true
|
|
}
|
|
|
|
func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int) {
|
|
switch {
|
|
case layout.clusters.contains(x, y):
|
|
m.focus = focusClusters
|
|
row := y - layout.clusters.y - 3
|
|
if row >= 0 {
|
|
index := m.clusterOff + row
|
|
if index >= 0 && index < len(m.payload.Clusters) {
|
|
m.selected = index
|
|
m.loadSelectedCluster()
|
|
}
|
|
}
|
|
case layout.members.contains(x, y):
|
|
m.focus = focusMembers
|
|
row := y - layout.members.y - 3
|
|
if row >= 0 {
|
|
index := m.memberOff + row
|
|
if index >= 0 && index < len(m.memberRows) {
|
|
if !m.memberRows[index].selectable {
|
|
m.memberIndex = index
|
|
return
|
|
}
|
|
previous := m.memberIndex
|
|
m.memberIndex = index
|
|
if m.memberIndex != previous {
|
|
m.detailView.GotoTop()
|
|
}
|
|
}
|
|
}
|
|
case layout.detail.contains(x, y):
|
|
m.focus = focusDetail
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) actionMenuContextAt(layout tuiLayout, x, y int) tuiFocus {
|
|
switch {
|
|
case layout.clusters.contains(x, y):
|
|
return focusClusters
|
|
case layout.members.contains(x, y):
|
|
return focusMembers
|
|
case layout.detail.contains(x, y):
|
|
return focusDetail
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openActionMenu() {
|
|
m.openActionMenuFor("")
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openActionMenuFor(context tuiFocus) {
|
|
if context == focusMembers {
|
|
if _, ok := m.selectedMember(); !ok {
|
|
context = focusClusters
|
|
}
|
|
}
|
|
if context == focusDetail {
|
|
if _, ok := m.selectedThread(); !ok {
|
|
context = focusClusters
|
|
}
|
|
}
|
|
|
|
items := make([]tuiMenuItem, 0, 32)
|
|
if context == "" {
|
|
m.appendThreadMenuItems(&items)
|
|
m.appendMemberClusterMenuItems(&items)
|
|
m.appendClusterMenuItems(&items, true)
|
|
m.appendReferenceLinkMenuItems(&items)
|
|
m.appendViewMenuItems(&items)
|
|
} else if context == focusMembers || context == focusDetail {
|
|
m.appendThreadMenuItems(&items)
|
|
m.appendMemberClusterMenuItems(&items)
|
|
m.appendReferenceLinkMenuItems(&items)
|
|
m.appendClusterContextMenuItems(&items)
|
|
m.appendViewMenuItems(&items)
|
|
} else if context == focusClusters {
|
|
m.appendClusterMenuItems(&items, true)
|
|
m.appendViewMenuItems(&items)
|
|
}
|
|
if len(items) == 0 {
|
|
items = append(items, tuiMenuItem{label: "No actions available", action: "close-menu"})
|
|
}
|
|
items = append(items, tuiMenuItem{label: "Close menu", action: "close-menu"})
|
|
|
|
m.menuItems = items
|
|
m.menuContext = context
|
|
m.menuTitle = actionMenuTitle(context)
|
|
m.menuIndex = m.firstSelectableMenuIndex()
|
|
m.menuOff = 0
|
|
m.menuOpen = true
|
|
m.showHelp = false
|
|
m.status = m.menuTitle
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendThreadMenuItems(items *[]tuiMenuItem) {
|
|
if thread, ok := m.selectedThread(); ok {
|
|
*items = append(*items,
|
|
tuiMenuSection("Thread"),
|
|
tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"},
|
|
tuiMenuItem{label: "Copy selected URL", action: "copy-url"},
|
|
tuiMenuItem{label: "Copy title", action: "copy-title"},
|
|
tuiMenuItem{label: "Copy markdown link", action: "copy-markdown"},
|
|
tuiMenuItem{label: "Copy selected detail", action: "copy-thread-detail"},
|
|
tuiMenuItem{label: "Load neighbors", action: "load-neighbors"},
|
|
)
|
|
if thread.ClosedAtLocal != "" {
|
|
*items = append(*items, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"})
|
|
} else {
|
|
*items = append(*items, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendMemberClusterMenuItems(items *[]tuiMenuItem) {
|
|
if member, ok := m.selectedMember(); ok {
|
|
sectionAdded := false
|
|
if cluster, clusterOK := m.selectedCluster(); clusterOK {
|
|
if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" {
|
|
if !sectionAdded {
|
|
*items = append(*items, tuiMenuSection("Member in cluster"))
|
|
sectionAdded = true
|
|
}
|
|
*items = append(*items, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"})
|
|
} else if clusterSupportsDurableLocalActions(cluster) {
|
|
if !sectionAdded {
|
|
*items = append(*items, tuiMenuSection("Member in cluster"))
|
|
sectionAdded = true
|
|
}
|
|
*items = append(*items,
|
|
tuiMenuItem{label: fmt.Sprintf("Exclude #%d from C%d...", member.Thread.Number, cluster.ID), action: "exclude-member-confirm"},
|
|
tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"},
|
|
)
|
|
}
|
|
}
|
|
if strings.TrimSpace(member.BodySnippet) != "" {
|
|
if !menuHasSection(*items, "Thread") {
|
|
*items = append(*items, tuiMenuSection("Thread"))
|
|
sectionAdded = true
|
|
}
|
|
*items = append(*items, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"})
|
|
}
|
|
if len(member.Summaries) > 0 {
|
|
if !sectionAdded && !menuHasSection(*items, "Thread") {
|
|
*items = append(*items, tuiMenuSection("Thread"))
|
|
sectionAdded = true
|
|
}
|
|
*items = append(*items, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"})
|
|
}
|
|
if _, ok := m.neighborCache[member.Thread.ID]; ok {
|
|
if !sectionAdded && !menuHasSection(*items, "Thread") {
|
|
*items = append(*items, tuiMenuSection("Thread"))
|
|
}
|
|
*items = append(*items, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendClusterMenuItems(items *[]tuiMenuItem, includeVisible bool) {
|
|
if m.hasSelectedCluster() {
|
|
*items = append(*items, tuiMenuSection("Cluster"))
|
|
if url, ok := m.selectedClusterURL(); ok {
|
|
cluster, _ := m.selectedCluster()
|
|
*items = append(*items,
|
|
tuiMenuItem{label: fmt.Sprintf("Open representative #%d", cluster.RepresentativeNumber), action: "open-cluster-representative", value: url},
|
|
tuiMenuItem{label: "Copy representative URL", action: "copy-cluster-url", value: url},
|
|
)
|
|
}
|
|
*items = append(*items,
|
|
tuiMenuItem{label: "Copy cluster ID", action: "copy-cluster-id"},
|
|
tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"},
|
|
tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"},
|
|
tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"},
|
|
)
|
|
cluster, _ := m.selectedCluster()
|
|
if clusterSupportsDurableLocalActions(cluster) {
|
|
if cluster.Status == "closed" || cluster.ClosedAt != "" {
|
|
*items = append(*items, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"})
|
|
} else {
|
|
*items = append(*items, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"})
|
|
}
|
|
}
|
|
if m.hasDetail {
|
|
*items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"})
|
|
}
|
|
}
|
|
if includeVisible && len(m.payload.Clusters) > 0 {
|
|
if !menuHasSection(*items, "Cluster") {
|
|
*items = append(*items, tuiMenuSection("Cluster"))
|
|
}
|
|
*items = append(*items, tuiMenuItem{label: "Copy visible clusters", action: "copy-visible-clusters"})
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendClusterContextMenuItems(items *[]tuiMenuItem) {
|
|
if !m.hasSelectedCluster() {
|
|
return
|
|
}
|
|
*items = append(*items,
|
|
tuiMenuSection("Cluster context"),
|
|
tuiMenuItem{label: "Copy cluster summary", action: "copy-cluster"},
|
|
)
|
|
if m.hasDetail {
|
|
*items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"})
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendReferenceLinkMenuItems(items *[]tuiMenuItem) {
|
|
referenceLinks := m.referenceLinks()
|
|
if len(referenceLinks) > 0 {
|
|
*items = append(*items,
|
|
tuiMenuSection("Links"),
|
|
tuiMenuItem{label: "Open first body link", action: "open-first-link"},
|
|
tuiMenuItem{label: "Copy first body link", action: "copy-first-link"},
|
|
)
|
|
}
|
|
if len(referenceLinks) > 1 {
|
|
*items = append(*items,
|
|
tuiMenuItem{label: "Open body link...", action: "open-link-picker"},
|
|
tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"},
|
|
tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"},
|
|
)
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) {
|
|
viewItems := []tuiMenuItem{
|
|
tuiMenuSection("View"),
|
|
tuiMenuItem{label: "Sort clusters by size", action: "sort-size"},
|
|
tuiMenuItem{label: "Sort clusters by recent", action: "sort-recent"},
|
|
tuiMenuItem{label: "Sort clusters by oldest", action: "sort-oldest"},
|
|
tuiMenuItem{label: "Member sort grouped", action: "member-sort-kind"},
|
|
tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"},
|
|
tuiMenuItem{label: "Member sort oldest", action: "member-sort-oldest"},
|
|
tuiMenuItem{label: "Filter clusters...", action: "filter"},
|
|
}
|
|
if strings.TrimSpace(m.search) != "" {
|
|
viewItems = append(viewItems, tuiMenuItem{label: "Clear filter", action: "clear-filter"})
|
|
}
|
|
viewItems = append(viewItems,
|
|
tuiMenuItem{label: "Refresh from store", action: "refresh"},
|
|
tuiMenuItem{label: "Switch repository...", action: "repository-picker"},
|
|
tuiMenuItem{label: "Jump to issue/PR...", action: "jump"},
|
|
tuiMenuItem{label: "Toggle layout", action: "toggle-layout"},
|
|
tuiMenuItem{label: detailModeToggleLabel(m.compactDetail), action: "toggle-detail"},
|
|
tuiMenuItem{label: "Min size 1+", action: "min-size-1"},
|
|
tuiMenuItem{label: "Min size 5+", action: "min-size-5"},
|
|
tuiMenuItem{label: "Min size 10+", action: "min-size-10"},
|
|
tuiMenuItem{label: closedToggleLabel(m.showClosed), action: "toggle-closed"},
|
|
tuiMenuItem{label: "Help", action: "show-help"},
|
|
tuiMenuItem{label: "Quit", action: "quit"},
|
|
)
|
|
*items = append(*items, viewItems...)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) clearMenuPlacement() {
|
|
m.menuFloating = false
|
|
m.menuRect = tuiRect{}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) closeMenu(status string) {
|
|
m.menuOpen = false
|
|
m.clearMenuPlacement()
|
|
if status != "" {
|
|
m.status = status
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) placeFloatingMenu(layout tuiLayout, x, y int) {
|
|
if !m.menuOpen {
|
|
return
|
|
}
|
|
maxWidth := maxInt(24, m.width-2)
|
|
width := clampInt(m.preferredMenuWidth(), 34, minInt(58, maxWidth))
|
|
availableHeight := maxInt(1, m.height-layout.header.h-layout.footer.h)
|
|
visibleRows := minInt(maxInt(1, len(m.menuItems)), 12)
|
|
height := minInt(visibleRows+7, availableHeight)
|
|
if height < minInt(8, availableHeight) {
|
|
height = minInt(8, availableHeight)
|
|
}
|
|
maxX := maxInt(0, m.width-width)
|
|
minY := layout.header.h
|
|
maxY := maxInt(minY, m.height-layout.footer.h-height)
|
|
m.menuFloating = true
|
|
m.menuRect = tuiRect{
|
|
x: clampInt(x+1, 0, maxX),
|
|
y: clampInt(y, minY, maxY),
|
|
w: width,
|
|
h: height,
|
|
}
|
|
m.keepMenuVisible()
|
|
}
|
|
|
|
func (m clusterBrowserModel) preferredMenuWidth() int {
|
|
width := lipgloss.Width(firstNonEmpty(m.menuTitle, "Actions")) + 4
|
|
for _, item := range m.menuItems {
|
|
width = maxInt(width, lipgloss.Width(item.label)+8)
|
|
}
|
|
return width
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openRepositoryMenu() {
|
|
if m.store == nil {
|
|
m.status = "Repository picker unavailable for this view"
|
|
return
|
|
}
|
|
repos, err := m.store.ListRepositories(m.ctx)
|
|
if err != nil {
|
|
m.status = "Repository picker failed: " + err.Error()
|
|
return
|
|
}
|
|
if len(repos) == 0 {
|
|
m.status = "No local repositories found"
|
|
return
|
|
}
|
|
items := make([]tuiMenuItem, 0, len(repos)+1)
|
|
currentIndex := 0
|
|
for _, repo := range repos {
|
|
label := repo.FullName
|
|
if repo.FullName == m.payload.Repository {
|
|
label = "* " + label
|
|
currentIndex = len(items)
|
|
}
|
|
items = append(items, tuiMenuItem{label: label, action: "select-repo", value: repo.FullName})
|
|
}
|
|
items = append(items, tuiMenuItem{label: "Back to actions", action: "back-to-actions"})
|
|
m.menuItems = items
|
|
m.menuTitle = "Repositories"
|
|
m.menuIndex = currentIndex
|
|
m.menuOff = 0
|
|
m.menuOpen = true
|
|
m.showHelp = false
|
|
m.searching = false
|
|
m.jumping = false
|
|
m.status = "Repository picker"
|
|
m.keepMenuVisible()
|
|
}
|
|
|
|
func (m *clusterBrowserModel) runAction(action string) bool {
|
|
return m.runMenuItem(tuiMenuItem{action: action})
|
|
}
|
|
|
|
func (m clusterBrowserModel) inMenuSubmenu() bool {
|
|
title := strings.TrimSpace(m.menuTitle)
|
|
return title != "" && title != "Actions"
|
|
}
|
|
|
|
func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
|
|
if !item.selectable() {
|
|
return false
|
|
}
|
|
action := item.action
|
|
if action == "close-menu" {
|
|
m.status = "Menu closed"
|
|
return true
|
|
}
|
|
switch action {
|
|
case "quit":
|
|
m.quitRequested = true
|
|
return true
|
|
case "sort-size":
|
|
m.payload.Sort = "size"
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: size"
|
|
return true
|
|
case "sort-recent":
|
|
m.payload.Sort = "recent"
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: recent"
|
|
return true
|
|
case "sort-oldest":
|
|
m.payload.Sort = "oldest"
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: oldest"
|
|
return true
|
|
case "member-sort-kind":
|
|
m.memberSort = memberSortKind
|
|
m.sortMembers()
|
|
m.status = "Member sort: kind"
|
|
return true
|
|
case "member-sort-recent":
|
|
m.memberSort = memberSortRecent
|
|
m.sortMembers()
|
|
m.status = "Member sort: recent"
|
|
return true
|
|
case "member-sort-oldest":
|
|
m.memberSort = memberSortOldest
|
|
m.sortMembers()
|
|
m.status = "Member sort: oldest"
|
|
return true
|
|
case "refresh":
|
|
m.refreshFromStore()
|
|
return true
|
|
case "filter":
|
|
m.startFilterInput()
|
|
return true
|
|
case "clear-filter":
|
|
m.search = ""
|
|
m.searchInput.SetValue("")
|
|
m.applyClusterFilters()
|
|
m.status = "Filter cleared"
|
|
return true
|
|
case "repository-picker":
|
|
m.openRepositoryMenu()
|
|
return false
|
|
case "jump":
|
|
m.startJumpInput()
|
|
return true
|
|
case "toggle-layout":
|
|
m.toggleWideLayout()
|
|
return true
|
|
case "toggle-detail":
|
|
m.toggleDetailMode()
|
|
return true
|
|
case "min-size-1":
|
|
m.setMinSizeFromMenu(1)
|
|
return true
|
|
case "min-size-5":
|
|
m.setMinSizeFromMenu(5)
|
|
return true
|
|
case "min-size-10":
|
|
m.setMinSizeFromMenu(10)
|
|
return true
|
|
case "toggle-closed":
|
|
m.toggleClosedVisibility()
|
|
return true
|
|
case "show-help":
|
|
m.showHelp = true
|
|
m.status = "Help"
|
|
return true
|
|
case "open-cluster-representative":
|
|
if strings.TrimSpace(item.value) == "" {
|
|
m.status = "No representative URL"
|
|
return true
|
|
}
|
|
openURL(item.value)
|
|
m.status = "Opened " + item.value
|
|
return true
|
|
case "copy-cluster-url":
|
|
if strings.TrimSpace(item.value) == "" {
|
|
m.status = "No representative URL"
|
|
return true
|
|
}
|
|
if err := copyText(item.value); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied representative URL"
|
|
}
|
|
return true
|
|
case "close-cluster-confirm":
|
|
m.openCloseClusterMenu()
|
|
return false
|
|
case "close-cluster-local":
|
|
m.closeSelectedClusterLocally()
|
|
return true
|
|
case "reopen-cluster-confirm":
|
|
m.openReopenClusterMenu()
|
|
return false
|
|
case "reopen-cluster-local":
|
|
m.reopenSelectedClusterLocally()
|
|
return true
|
|
case "exclude-member-confirm":
|
|
m.openExcludeMemberMenu()
|
|
return false
|
|
case "exclude-member-local":
|
|
m.excludeSelectedClusterMemberLocally()
|
|
return true
|
|
case "include-member-confirm":
|
|
m.openIncludeMemberMenu()
|
|
return false
|
|
case "include-member-local":
|
|
m.includeSelectedClusterMemberLocally()
|
|
return true
|
|
case "canonical-member-confirm":
|
|
m.openCanonicalMemberMenu()
|
|
return false
|
|
case "canonical-member-local":
|
|
m.setSelectedClusterCanonicalLocally()
|
|
return true
|
|
case "load-neighbors":
|
|
m.loadSelectedThreadNeighbors(10, 0.2)
|
|
return true
|
|
case "close-thread-confirm":
|
|
m.openCloseThreadMenu()
|
|
return false
|
|
case "close-thread-local":
|
|
m.closeSelectedThreadLocally()
|
|
return true
|
|
case "reopen-thread-confirm":
|
|
m.openReopenThreadMenu()
|
|
return false
|
|
case "reopen-thread-local":
|
|
m.reopenSelectedThreadLocally()
|
|
return true
|
|
case "copy-thread-detail":
|
|
if err := copyText(m.threadDetailClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied selected detail"
|
|
}
|
|
return true
|
|
case "copy-body-preview":
|
|
member, ok := m.selectedMember()
|
|
if !ok || strings.TrimSpace(member.BodySnippet) == "" {
|
|
m.status = "No body preview"
|
|
return true
|
|
}
|
|
if err := copyText(member.BodySnippet); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied body preview"
|
|
}
|
|
return true
|
|
case "copy-summaries":
|
|
if err := copyText(m.summariesClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied summaries"
|
|
}
|
|
return true
|
|
case "copy-neighbors":
|
|
if err := copyText(m.neighborsClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied neighbors"
|
|
}
|
|
return true
|
|
case "copy-cluster-id":
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return true
|
|
}
|
|
if err := copyText(fmt.Sprintf("%d", cluster.ID)); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied cluster ID"
|
|
}
|
|
return true
|
|
case "copy-cluster-name":
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return true
|
|
}
|
|
if err := copyText(cluster.StableSlug); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied cluster name"
|
|
}
|
|
return true
|
|
case "copy-cluster-title":
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return true
|
|
}
|
|
if err := copyText(firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster")); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied cluster title"
|
|
}
|
|
return true
|
|
case "copy-member-list":
|
|
if err := copyText(m.memberListClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied member list"
|
|
}
|
|
return true
|
|
case "back-to-actions":
|
|
m.openActionMenuFor(m.menuContext)
|
|
return false
|
|
case "select-repo":
|
|
m.switchRepository(item.value)
|
|
return true
|
|
case "open-link-picker":
|
|
m.openReferenceLinkMenu("open")
|
|
return false
|
|
case "copy-link-picker":
|
|
m.openReferenceLinkMenu("copy")
|
|
return false
|
|
case "open-picked-link":
|
|
if err := openURL(item.value); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Opened " + item.value
|
|
}
|
|
return true
|
|
case "copy-picked-link":
|
|
if err := copyText(item.value); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied body link"
|
|
}
|
|
return true
|
|
case "copy-cluster":
|
|
if err := copyText(m.clusterClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied cluster summary"
|
|
}
|
|
return true
|
|
case "copy-visible-clusters":
|
|
if err := copyText(m.visibleClustersClipboardText()); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied visible clusters"
|
|
}
|
|
return true
|
|
case "copy-reference-links":
|
|
links := m.referenceLinks()
|
|
if len(links) == 0 {
|
|
m.status = "No body links found"
|
|
return true
|
|
}
|
|
if err := copyText(strings.Join(links, "\n")); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied body links"
|
|
}
|
|
return true
|
|
}
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
if action == "open" || action == "copy-url" {
|
|
url, urlOK := m.selectedActionURL()
|
|
if !urlOK {
|
|
m.status = "No selected thread"
|
|
return true
|
|
}
|
|
if action == "open" {
|
|
if err := openURL(url); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Opened " + url
|
|
}
|
|
return true
|
|
}
|
|
if err := copyText(url); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied representative URL"
|
|
}
|
|
return true
|
|
}
|
|
m.status = "No selected thread"
|
|
return true
|
|
}
|
|
switch action {
|
|
case "open":
|
|
if err := openURL(thread.HTMLURL); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Opened " + thread.HTMLURL
|
|
}
|
|
case "copy-url":
|
|
if err := copyText(thread.HTMLURL); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied URL"
|
|
}
|
|
case "copy-markdown":
|
|
link := fmt.Sprintf("[#%d %s](%s)", thread.Number, thread.Title, thread.HTMLURL)
|
|
if err := copyText(link); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied markdown link"
|
|
}
|
|
case "copy-title":
|
|
title := fmt.Sprintf("#%d %s", thread.Number, thread.Title)
|
|
if err := copyText(title); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied title"
|
|
}
|
|
case "open-first-link":
|
|
link, ok := m.firstReferenceLink()
|
|
if !ok {
|
|
m.status = "No body link found"
|
|
return true
|
|
}
|
|
if err := openURL(link); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Opened " + link
|
|
}
|
|
case "copy-first-link":
|
|
link, ok := m.firstReferenceLink()
|
|
if !ok {
|
|
m.status = "No body link found"
|
|
return true
|
|
}
|
|
if err := copyText(link); err != nil {
|
|
m.status = err.Error()
|
|
} else {
|
|
m.status = "Copied first body link"
|
|
}
|
|
case "close-menu":
|
|
m.status = "Menu closed"
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *clusterBrowserModel) setMinSizeFromMenu(value int) {
|
|
m.minSize = maxInt(1, value)
|
|
m.applyClusterFilters()
|
|
m.status = fmt.Sprintf("Min size: %s", minSizeLabel(m.minSize))
|
|
}
|
|
|
|
func (m *clusterBrowserModel) toggleClosedVisibility() {
|
|
m.showClosed = !m.showClosed
|
|
if m.store != nil && m.repoID != 0 {
|
|
m.refreshFromStore()
|
|
} else {
|
|
m.applyClusterFilters()
|
|
}
|
|
if m.showClosed {
|
|
m.status = "Showing closed clusters and members"
|
|
} else {
|
|
m.status = "Hiding closed clusters and members"
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) loadSelectedThreadNeighbors(limit int, threshold float64) {
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
m.status = "No selected thread"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Neighbors unavailable for this view"
|
|
return
|
|
}
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
if threshold <= 0 {
|
|
threshold = 0.2
|
|
}
|
|
targetThread, targetVector, err := m.store.ThreadVectorByNumber(m.ctx, store.ThreadVectorQuery{
|
|
RepoID: m.repoID,
|
|
Model: m.payload.EmbedModel,
|
|
Basis: m.payload.EmbeddingBasis,
|
|
}, thread.Number)
|
|
if err != nil {
|
|
var fallbackErr error
|
|
targetThread, targetVector, fallbackErr = m.store.ThreadVectorByNumber(m.ctx, store.ThreadVectorQuery{RepoID: m.repoID}, thread.Number)
|
|
if fallbackErr != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
}
|
|
vectors, err := m.store.ListThreadVectorsFiltered(m.ctx, store.ThreadVectorQuery{
|
|
RepoID: m.repoID,
|
|
Model: targetVector.Model,
|
|
Basis: targetVector.Basis,
|
|
Dimensions: targetVector.Dimensions,
|
|
})
|
|
if err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
items := make([]vector.Item, 0, len(vectors))
|
|
for _, stored := range vectors {
|
|
items = append(items, vector.Item{ThreadID: stored.ThreadID, Vector: stored.Vector})
|
|
}
|
|
candidates := vector.Query(items, targetVector.Vector, limit*2, targetThread.ID)
|
|
filtered := make([]vector.Neighbor, 0, limit)
|
|
for _, candidate := range candidates {
|
|
if candidate.Score < threshold {
|
|
continue
|
|
}
|
|
filtered = append(filtered, candidate)
|
|
if len(filtered) >= limit {
|
|
break
|
|
}
|
|
}
|
|
ids := make([]int64, 0, len(filtered))
|
|
for _, candidate := range filtered {
|
|
ids = append(ids, candidate.ThreadID)
|
|
}
|
|
threads, err := m.store.ThreadsByIDs(m.ctx, m.repoID, ids)
|
|
if err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
neighbors := make([]tuiNeighbor, 0, len(filtered))
|
|
for _, candidate := range filtered {
|
|
neighborThread, ok := threads[candidate.ThreadID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
neighbors = append(neighbors, tuiNeighbor{Thread: neighborThread, Score: candidate.Score})
|
|
}
|
|
m.neighborCache[targetThread.ID] = neighbors
|
|
m.focus = focusDetail
|
|
m.detailView.GotoTop()
|
|
m.status = fmt.Sprintf("Loaded %d neighbors for #%d", len(neighbors), targetThread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openReferenceLinkMenu(mode string) {
|
|
links := m.referenceLinks()
|
|
if len(links) == 0 {
|
|
m.status = "No body links found"
|
|
return
|
|
}
|
|
action := "copy-picked-link"
|
|
m.menuTitle = "Copy Link"
|
|
if mode == "open" {
|
|
action = "open-picked-link"
|
|
m.menuTitle = "Open Link"
|
|
}
|
|
items := make([]tuiMenuItem, 0, len(links)+1)
|
|
for index, link := range links {
|
|
items = append(items, tuiMenuItem{
|
|
label: formatLinkChoiceLabel(link, index),
|
|
action: action,
|
|
value: link,
|
|
})
|
|
}
|
|
items = append(items, tuiMenuItem{label: "Back to actions", action: "back-to-actions"})
|
|
m.menuItems = items
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = m.menuTitle
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openCloseThreadMenu() {
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
m.status = "No selected thread"
|
|
return
|
|
}
|
|
m.menuTitle = "Close Locally"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Close #%d locally", thread.Number), action: "close-thread-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local close for #%d", thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openReopenThreadMenu() {
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
m.status = "No selected thread"
|
|
return
|
|
}
|
|
m.menuTitle = "Reopen Locally"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Reopen #%d locally", thread.Number), action: "reopen-thread-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local reopen for #%d", thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openCloseClusterMenu() {
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return
|
|
}
|
|
m.menuTitle = "Close Cluster"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Close cluster C%d locally", cluster.ID), action: "close-cluster-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local close for cluster C%d", cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openReopenClusterMenu() {
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return
|
|
}
|
|
m.menuTitle = "Reopen Cluster"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Reopen cluster C%d locally", cluster.ID), action: "reopen-cluster-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local reopen for cluster C%d", cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openExcludeMemberMenu() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
m.menuTitle = "Exclude Member"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Exclude #%d from C%d", member.Thread.Number, cluster.ID), action: "exclude-member-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local exclude for #%d", member.Thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openIncludeMemberMenu() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
m.menuTitle = "Include Member"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Include #%d in C%d", member.Thread.Number, cluster.ID), action: "include-member-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm local include for #%d", member.Thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) openCanonicalMemberMenu() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
m.menuTitle = "Canonical Member"
|
|
m.menuItems = []tuiMenuItem{
|
|
{label: fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID), action: "canonical-member-local"},
|
|
{label: "Back to actions", action: "back-to-actions"},
|
|
}
|
|
m.menuIndex = 0
|
|
m.menuOff = 0
|
|
m.status = fmt.Sprintf("Confirm canonical member #%d", member.Thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) closeSelectedThreadLocally() {
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
m.status = "No selected thread"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local close unavailable for this view"
|
|
return
|
|
}
|
|
if err := m.store.CloseThreadLocally(m.ctx, m.repoID, thread.Number, "TUI manual close"); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
delete(m.neighborCache, thread.ID)
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Closed #%d locally", thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) reopenSelectedThreadLocally() {
|
|
thread, ok := m.selectedThread()
|
|
if !ok {
|
|
m.status = "No selected thread"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local reopen unavailable for this view"
|
|
return
|
|
}
|
|
if err := m.store.ReopenThreadLocally(m.ctx, m.repoID, thread.Number); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Reopened #%d locally", thread.Number)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) closeSelectedClusterLocally() {
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return
|
|
}
|
|
if !clusterSupportsDurableLocalActions(cluster) {
|
|
m.status = "Local cluster close is only available for durable clusters"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local cluster close unavailable for this view"
|
|
return
|
|
}
|
|
if err := m.store.CloseClusterLocally(m.ctx, m.repoID, cluster.ID, "TUI manual close"); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Closed cluster C%d locally", cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) reopenSelectedClusterLocally() {
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok {
|
|
m.status = "No selected cluster"
|
|
return
|
|
}
|
|
if !clusterSupportsDurableLocalActions(cluster) {
|
|
m.status = "Local cluster reopen is only available for durable clusters"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local cluster reopen unavailable for this view"
|
|
return
|
|
}
|
|
if err := m.store.ReopenClusterLocally(m.ctx, m.repoID, cluster.ID); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Reopened cluster C%d locally", cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) excludeSelectedClusterMemberLocally() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
if !clusterSupportsDurableLocalActions(cluster) {
|
|
m.status = "Local member triage is only available for durable clusters"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local member exclude unavailable for this view"
|
|
return
|
|
}
|
|
if _, err := m.store.ExcludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual exclude"); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
delete(m.neighborCache, member.Thread.ID)
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Excluded #%d from C%d locally", member.Thread.Number, cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) includeSelectedClusterMemberLocally() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
if !clusterSupportsDurableLocalActions(cluster) {
|
|
m.status = "Local member triage is only available for durable clusters"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local member include unavailable for this view"
|
|
return
|
|
}
|
|
if _, err := m.store.IncludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual include"); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Included #%d in C%d locally", member.Thread.Number, cluster.ID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) setSelectedClusterCanonicalLocally() {
|
|
cluster, clusterOK := m.selectedCluster()
|
|
member, memberOK := m.selectedMember()
|
|
if !clusterOK || !memberOK {
|
|
m.status = "No selected cluster member"
|
|
return
|
|
}
|
|
if !clusterSupportsDurableLocalActions(cluster) {
|
|
m.status = "Local member triage is only available for durable clusters"
|
|
return
|
|
}
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Local canonical unavailable for this view"
|
|
return
|
|
}
|
|
if _, err := m.store.SetClusterCanonicalLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual canonical"); err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.refreshFromStore()
|
|
m.status = fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID)
|
|
}
|
|
|
|
func (m clusterBrowserModel) menuVisibleCount() int {
|
|
if m.menuFloating && m.menuRect.h > 0 {
|
|
return maxInt(1, m.menuRect.h-7)
|
|
}
|
|
height := m.detailView.Height
|
|
if height <= 0 {
|
|
height = maxInt(1, m.layout().detail.h-2)
|
|
}
|
|
return maxInt(1, height-4)
|
|
}
|
|
|
|
func visibleMenuShortcutIndex(key string, items []tuiMenuItem, menuOff, visible int) (int, bool) {
|
|
if len(key) != 1 || key[0] < '1' || key[0] > '9' {
|
|
return 0, false
|
|
}
|
|
want := int(key[0] - '0')
|
|
seen := 0
|
|
end := minInt(len(items), menuOff+maxInt(1, visible))
|
|
for index := menuOff; index < end; index++ {
|
|
if !items[index].selectable() {
|
|
continue
|
|
}
|
|
seen++
|
|
if seen == want {
|
|
return index, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (m clusterBrowserModel) firstSelectableMenuIndex() int {
|
|
for index, item := range m.menuItems {
|
|
if item.selectable() {
|
|
return index
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m clusterBrowserModel) lastSelectableMenuIndex() int {
|
|
for index := len(m.menuItems) - 1; index >= 0; index-- {
|
|
if m.menuItems[index].selectable() {
|
|
return index
|
|
}
|
|
}
|
|
return maxInt(0, len(m.menuItems)-1)
|
|
}
|
|
|
|
func (m clusterBrowserModel) nextSelectableMenuIndex(delta int) int {
|
|
if delta == 0 || len(m.menuItems) == 0 {
|
|
return m.menuIndex
|
|
}
|
|
for index := m.menuIndex + delta; index >= 0 && index < len(m.menuItems); index += delta {
|
|
if m.menuItems[index].selectable() {
|
|
return index
|
|
}
|
|
}
|
|
return m.menuIndex
|
|
}
|
|
|
|
func (m clusterBrowserModel) nearestSelectableMenuIndex(index, direction int) int {
|
|
if len(m.menuItems) == 0 {
|
|
return 0
|
|
}
|
|
index = clampInt(index, 0, len(m.menuItems)-1)
|
|
if m.menuItems[index].selectable() {
|
|
return index
|
|
}
|
|
if direction == 0 {
|
|
direction = 1
|
|
}
|
|
for next := index + direction; next >= 0 && next < len(m.menuItems); next += direction {
|
|
if m.menuItems[next].selectable() {
|
|
return next
|
|
}
|
|
}
|
|
if direction > 0 {
|
|
return m.lastSelectableMenuIndex()
|
|
}
|
|
return m.firstSelectableMenuIndex()
|
|
}
|
|
|
|
func (m *clusterBrowserModel) keepMenuVisible() {
|
|
if len(m.menuItems) == 0 {
|
|
m.menuOff = 0
|
|
return
|
|
}
|
|
visible := m.menuVisibleCount()
|
|
m.menuIndex = m.nearestSelectableMenuIndex(m.menuIndex, 1)
|
|
if m.menuIndex > 0 && !m.menuItems[m.menuIndex-1].selectable() && m.menuIndex-1 < m.menuOff {
|
|
m.menuOff = m.menuIndex - 1
|
|
} else if m.menuIndex < m.menuOff {
|
|
m.menuOff = m.menuIndex
|
|
}
|
|
if m.menuIndex >= m.menuOff+visible {
|
|
m.menuOff = m.menuIndex - visible + 1
|
|
}
|
|
m.menuOff = clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible))
|
|
}
|
|
|
|
func isMouseWheel(button tea.MouseButton) bool {
|
|
return button == tea.MouseButtonWheelUp || button == tea.MouseButtonWheelDown || button == tea.MouseButtonWheelLeft || button == tea.MouseButtonWheelRight
|
|
}
|
|
|
|
func (m *clusterBrowserModel) mouseWheel(layout tuiLayout, msg tea.MouseMsg, delta int) tea.Cmd {
|
|
m.clearLastClick()
|
|
switch {
|
|
case layout.clusters.contains(msg.X, msg.Y):
|
|
return m.queueWheelScroll(focusClusters, delta)
|
|
case layout.members.contains(msg.X, msg.Y):
|
|
return m.queueWheelScroll(focusMembers, delta)
|
|
case layout.detail.contains(msg.X, msg.Y):
|
|
return m.queueWheelScroll(focusDetail, delta)
|
|
default:
|
|
return m.queueWheelScroll(m.focus, delta)
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) queueWheelScroll(focus tuiFocus, delta int) tea.Cmd {
|
|
if delta == 0 {
|
|
return nil
|
|
}
|
|
if m.wheelPending && m.wheelFocus != focus {
|
|
m.cancelQueuedWheelScroll()
|
|
}
|
|
m.focus = focus
|
|
m.wheelFocus = focus
|
|
m.wheelDelta = clampInt(m.wheelDelta+delta, -tuiWheelMaxBufferedDelta, tuiWheelMaxBufferedDelta)
|
|
if m.wheelPending {
|
|
return nil
|
|
}
|
|
m.wheelPending = true
|
|
m.wheelScrollSeq++
|
|
seq := m.wheelScrollSeq
|
|
return tea.Tick(tuiWheelScrollDelay, func(time.Time) tea.Msg {
|
|
return tuiWheelScrollMsg{seq: seq}
|
|
})
|
|
}
|
|
|
|
func (m *clusterBrowserModel) cancelQueuedWheelScroll() {
|
|
if !m.wheelPending && m.wheelDelta == 0 {
|
|
return
|
|
}
|
|
m.wheelPending = false
|
|
m.wheelDelta = 0
|
|
m.wheelScrollSeq++
|
|
}
|
|
|
|
func (m *clusterBrowserModel) applyQueuedWheelScroll() tea.Cmd {
|
|
delta := m.wheelDelta
|
|
focus := m.wheelFocus
|
|
m.wheelPending = false
|
|
m.wheelDelta = 0
|
|
if delta == 0 {
|
|
return nil
|
|
}
|
|
switch focus {
|
|
case focusClusters:
|
|
m.focus = focusClusters
|
|
return m.moveClusterByWheel(delta)
|
|
case focusMembers:
|
|
m.focus = focusMembers
|
|
m.move(delta)
|
|
case focusDetail:
|
|
m.focus = focusDetail
|
|
m.move(delta)
|
|
default:
|
|
m.move(delta)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) moveClusterByWheel(delta int) tea.Cmd {
|
|
if len(m.payload.Clusters) == 0 {
|
|
return nil
|
|
}
|
|
previous := m.selected
|
|
m.selected = clampInt(m.selected+delta, 0, len(m.payload.Clusters)-1)
|
|
if m.selected == previous {
|
|
return nil
|
|
}
|
|
m.status = fmt.Sprintf("Cluster %d", m.payload.Clusters[m.selected].ID)
|
|
m.wheelSeq++
|
|
seq := m.wheelSeq
|
|
return tea.Tick(tuiWheelSettleDelay, func(time.Time) tea.Msg {
|
|
return tuiWheelSettledMsg{seq: seq}
|
|
})
|
|
}
|
|
|
|
func (m *clusterBrowserModel) jumpEdge(end bool) {
|
|
if m.focus == focusDetail {
|
|
if end {
|
|
m.detailView.GotoBottom()
|
|
} else {
|
|
m.detailView.GotoTop()
|
|
}
|
|
return
|
|
}
|
|
if m.focus == focusMembers && len(m.memberRows) > 0 {
|
|
previous := m.memberIndex
|
|
if end {
|
|
m.memberIndex = m.lastSelectableMemberIndex()
|
|
} else {
|
|
m.memberIndex = m.firstSelectableMemberIndex()
|
|
}
|
|
if m.memberIndex != previous {
|
|
m.detailView.GotoTop()
|
|
}
|
|
return
|
|
}
|
|
if len(m.payload.Clusters) > 0 {
|
|
if end {
|
|
m.selected = len(m.payload.Clusters) - 1
|
|
} else {
|
|
m.selected = 0
|
|
}
|
|
m.loadSelectedCluster()
|
|
}
|
|
}
|
|
|
|
func (r tuiRect) contains(x, y int) bool {
|
|
return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h
|
|
}
|
|
|
|
func (m *clusterBrowserModel) keepVisible() {
|
|
m.clusterOff = keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight())
|
|
m.memberOff = keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight())
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterVisibleStart() int {
|
|
return keepRowVisible(m.clusterOff, m.selected, len(m.payload.Clusters), m.clusterViewportHeight())
|
|
}
|
|
|
|
func (m clusterBrowserModel) memberVisibleStart() int {
|
|
return keepRowVisible(m.memberOff, m.memberIndex, len(m.memberRows), m.memberViewportHeight())
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterViewportHeight() int {
|
|
return tableViewportHeight(m.layout().clusters)
|
|
}
|
|
|
|
func (m clusterBrowserModel) memberViewportHeight() int {
|
|
return tableViewportHeight(m.layout().members)
|
|
}
|
|
|
|
func tableViewportWidth(rect tuiRect) int {
|
|
return maxInt(24, rect.w-4)
|
|
}
|
|
|
|
func tableViewportHeight(rect tuiRect) int {
|
|
return maxInt(1, maxInt(2, rect.h-3)-1)
|
|
}
|
|
|
|
func keepRowVisible(offset, selected, rowCount, viewportHeight int) int {
|
|
if rowCount <= 0 || selected < 0 {
|
|
return 0
|
|
}
|
|
viewportHeight = maxInt(1, viewportHeight)
|
|
selected = clampInt(selected, 0, rowCount-1)
|
|
maxOffset := maxInt(0, rowCount-viewportHeight)
|
|
offset = clampInt(offset, 0, maxOffset)
|
|
if selected < offset {
|
|
return selected
|
|
}
|
|
if selected >= offset+viewportHeight {
|
|
return clampInt(selected-viewportHeight+1, 0, maxOffset)
|
|
}
|
|
return offset
|
|
}
|
|
|
|
func (m *clusterBrowserModel) syncComponents() {
|
|
layout := m.layout()
|
|
detailW := maxInt(24, layout.detail.w-4)
|
|
detailH := maxInt(2, layout.detail.h-2)
|
|
|
|
m.detailView.Width = detailW
|
|
m.detailView.Height = detailH
|
|
m.detailView.MouseWheelEnabled = true
|
|
m.detailView.MouseWheelDelta = 3
|
|
m.searchInput.Width = maxInt(20, m.width-16)
|
|
}
|
|
|
|
func renderStyledTable(columns []table.Column, rows []table.Row, offset, height, width int, headerColor string, styleForRow func(index int) lipgloss.Style) string {
|
|
height = maxInt(1, height)
|
|
width = maxInt(1, width)
|
|
lines := make([]string, 0, height+1)
|
|
lines = append(lines, renderTableHeader(columns, width, headerColor))
|
|
for line := 0; line < height; line++ {
|
|
index := offset + line
|
|
if index < 0 || index >= len(rows) {
|
|
lines = append(lines, lipgloss.NewStyle().Width(width).Render(""))
|
|
continue
|
|
}
|
|
lines = append(lines, renderTableRow(columns, rows[index], width, styleForRow(index)))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderTableHeader(columns []table.Column, width int, headerColor string) string {
|
|
values := make(table.Row, 0, len(columns))
|
|
for _, column := range columns {
|
|
values = append(values, column.Title)
|
|
}
|
|
line := truncateCells(renderTableCells(columns, values), width)
|
|
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(headerColor)).Width(width).Render(line)
|
|
}
|
|
|
|
func renderTableRow(columns []table.Column, row table.Row, width int, rowStyle lipgloss.Style) string {
|
|
line := truncateCells(renderTableCells(columns, row), width)
|
|
return rowStyle.Width(width).Render(line)
|
|
}
|
|
|
|
func renderTableCells(columns []table.Column, row table.Row) string {
|
|
cells := make([]string, 0, min(len(columns), len(row)))
|
|
cellStyle := lipgloss.NewStyle().Padding(0, 1, 0, 0)
|
|
for index, value := range row {
|
|
if index >= len(columns) || columns[index].Width <= 0 {
|
|
continue
|
|
}
|
|
column := columns[index]
|
|
cell := lipgloss.NewStyle().Width(column.Width).MaxWidth(column.Width).Inline(true).Render(truncateCells(value, column.Width))
|
|
cells = append(cells, cellStyle.Render(cell))
|
|
}
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, cells...)
|
|
}
|
|
|
|
func clusterColumns(width int, sortMode string) []table.Column {
|
|
width = maxInt(28, width)
|
|
available := maxInt(30, width-5)
|
|
idW := 7
|
|
cntW := 4
|
|
stateW := 7
|
|
kindW := 3
|
|
ageW := 7
|
|
clusterW := clampInt(available/4, 10, 16)
|
|
titleW := maxInt(8, available-idW-cntW-stateW-clusterW-kindW-ageW)
|
|
cntTitle := "cnt"
|
|
ageTitle := "age"
|
|
if sortMode == "size" {
|
|
cntTitle = "cnt*"
|
|
}
|
|
if sortMode == "recent" {
|
|
ageTitle = "age-"
|
|
}
|
|
if sortMode == "oldest" {
|
|
ageTitle = "age+"
|
|
}
|
|
return []table.Column{
|
|
{Title: "id", Width: idW},
|
|
{Title: cntTitle, Width: cntW},
|
|
{Title: "state", Width: stateW},
|
|
{Title: "cluster", Width: clusterW},
|
|
{Title: "title", Width: titleW},
|
|
{Title: "k", Width: kindW},
|
|
{Title: ageTitle, Width: ageW},
|
|
}
|
|
}
|
|
|
|
func memberColumns(width int, sortMode tuiMemberSort) []table.Column {
|
|
width = maxInt(28, width)
|
|
available := maxInt(24, width-4)
|
|
numberW := 8
|
|
stateW := 4
|
|
ageW := 7
|
|
titleW := maxInt(8, available-numberW-stateW-ageW)
|
|
numberTitle := "number"
|
|
stateTitle := "st"
|
|
ageTitle := "age"
|
|
titleTitle := "title"
|
|
if sortMode == memberSortNumber {
|
|
numberTitle = "number*"
|
|
}
|
|
if sortMode == memberSortState {
|
|
stateTitle = "st*"
|
|
}
|
|
if sortMode == memberSortRecent {
|
|
ageTitle = "age-"
|
|
}
|
|
if sortMode == memberSortOldest {
|
|
ageTitle = "age+"
|
|
}
|
|
if sortMode == memberSortTitle {
|
|
titleTitle = "title*"
|
|
}
|
|
return []table.Column{
|
|
{Title: numberTitle, Width: numberW},
|
|
{Title: stateTitle, Width: stateW},
|
|
{Title: ageTitle, Width: ageW},
|
|
{Title: titleTitle, Width: titleW},
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterRows() []table.Row {
|
|
if len(m.payload.Clusters) == 0 {
|
|
return []table.Row{{"", "", "", "", "No clusters visible. Press f, /, x, or r.", "", ""}}
|
|
}
|
|
rows := make([]table.Row, 0, len(m.payload.Clusters))
|
|
for _, cluster := range m.payload.Clusters {
|
|
rows = append(rows, table.Row{
|
|
fmt.Sprintf("C%d", cluster.ID),
|
|
fmt.Sprintf("%d", cluster.MemberCount),
|
|
clusterStateLabel(cluster),
|
|
cluster.StableSlug,
|
|
splitClusterTitle(cluster),
|
|
kindGlyph(cluster.RepresentativeKind),
|
|
formatRelativeTime(cluster.UpdatedAt),
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (m clusterBrowserModel) memberTableRows() []table.Row {
|
|
if len(m.memberRows) == 0 {
|
|
return []table.Row{{"", "", "", "Select a cluster to inspect members."}}
|
|
}
|
|
rows := make([]table.Row, 0, len(m.memberRows))
|
|
for _, member := range m.memberRows {
|
|
if !member.selectable {
|
|
rows = append(rows, table.Row{"", "", "", member.label})
|
|
continue
|
|
}
|
|
thread := member.thread()
|
|
rows = append(rows, table.Row{
|
|
fmt.Sprintf("#%d", thread.Number),
|
|
stateGlyph(memberDisplayState(member.member)),
|
|
formatRelativeTime(thread.UpdatedAtGitHub),
|
|
renderTitleText(thread.Title),
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (m clusterBrowserModel) pageStep() int {
|
|
switch m.focus {
|
|
case focusMembers:
|
|
return m.memberViewportHeight()
|
|
case focusDetail:
|
|
return maxInt(1, m.detailView.Height)
|
|
default:
|
|
return m.clusterViewportHeight()
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) sortClusters() {
|
|
sort.SliceStable(m.payload.Clusters, func(i, j int) bool {
|
|
left := m.payload.Clusters[i]
|
|
right := m.payload.Clusters[j]
|
|
if m.payload.Sort == "size" {
|
|
if left.MemberCount != right.MemberCount {
|
|
return left.MemberCount > right.MemberCount
|
|
}
|
|
}
|
|
if m.payload.Sort == "oldest" {
|
|
leftUpdated := parseTime(left.UpdatedAt)
|
|
rightUpdated := parseTime(right.UpdatedAt)
|
|
if !leftUpdated.Equal(rightUpdated) {
|
|
return leftUpdated.Before(rightUpdated)
|
|
}
|
|
return left.ID < right.ID
|
|
}
|
|
return parseTime(left.UpdatedAt).After(parseTime(right.UpdatedAt))
|
|
})
|
|
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1))
|
|
}
|
|
|
|
func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) {
|
|
columns := clusterColumns(maxInt(24, m.layout().clusters.w-4), m.payload.Sort)
|
|
if relativeX < columnRightEdge(columns, 1) {
|
|
m.payload.Sort = "size"
|
|
} else if relativeX >= columnLeftEdge(columns, len(columns)-1) {
|
|
if m.payload.Sort == "recent" {
|
|
m.payload.Sort = "oldest"
|
|
} else {
|
|
m.payload.Sort = "recent"
|
|
}
|
|
} else if m.payload.Sort == "recent" {
|
|
m.payload.Sort = "size"
|
|
} else {
|
|
m.payload.Sort = "recent"
|
|
}
|
|
m.sortClusters()
|
|
m.loadSelectedCluster()
|
|
m.status = "Sort: " + m.payload.Sort
|
|
}
|
|
|
|
func (m *clusterBrowserModel) jumpToThreadNumber(number int) {
|
|
if number <= 0 {
|
|
m.status = "Enter a positive issue or PR number"
|
|
return
|
|
}
|
|
clusterID := m.findLoadedClusterIDForThreadNumber(number)
|
|
if clusterID == 0 && m.store != nil && m.repoID != 0 {
|
|
foundID, err := m.store.ClusterIDForThreadNumber(m.ctx, m.repoID, number, true)
|
|
if err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
clusterID = foundID
|
|
if _, ok := m.detailCache[clusterID]; !ok {
|
|
detail, err := m.store.ClusterDetail(m.ctx, store.ClusterDetailOptions{
|
|
RepoID: m.repoID,
|
|
ClusterID: clusterID,
|
|
IncludeClosed: true,
|
|
MemberLimit: 200,
|
|
BodyChars: 1600,
|
|
})
|
|
if err != nil {
|
|
m.status = "Jump failed: " + err.Error()
|
|
return
|
|
}
|
|
m.detailCache[clusterID] = detail
|
|
m.ensureClusterInWorkingSet(detail.Cluster)
|
|
}
|
|
}
|
|
if clusterID == 0 {
|
|
m.status = fmt.Sprintf("Thread #%d was not found in loaded clusters", number)
|
|
return
|
|
}
|
|
if !m.selectClusterIDForJump(clusterID) {
|
|
m.status = fmt.Sprintf("Cluster %d is not available in this view", clusterID)
|
|
return
|
|
}
|
|
if m.selectMemberByNumber(number) {
|
|
m.focus = focusMembers
|
|
m.status = fmt.Sprintf("Jumped to #%d", number)
|
|
return
|
|
}
|
|
m.focus = focusMembers
|
|
m.status = fmt.Sprintf("Jumped to cluster %d; #%d is outside loaded members", clusterID, number)
|
|
}
|
|
|
|
func (m clusterBrowserModel) findLoadedClusterIDForThreadNumber(number int) int64 {
|
|
if m.hasDetail {
|
|
for _, member := range m.detail.Members {
|
|
if member.Thread.Number == number {
|
|
return m.detail.Cluster.ID
|
|
}
|
|
}
|
|
}
|
|
for _, detail := range m.detailCache {
|
|
for _, member := range detail.Members {
|
|
if member.Thread.Number == number {
|
|
return detail.Cluster.ID
|
|
}
|
|
}
|
|
}
|
|
for _, cluster := range m.allClusters {
|
|
if cluster.RepresentativeNumber == number {
|
|
return cluster.ID
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *clusterBrowserModel) ensureClusterInWorkingSet(cluster store.ClusterSummary) {
|
|
if cluster.ID == 0 {
|
|
return
|
|
}
|
|
for _, existing := range m.allClusters {
|
|
if existing.ID == cluster.ID {
|
|
return
|
|
}
|
|
}
|
|
m.allClusters = append(m.allClusters, cluster)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) selectClusterIDForJump(clusterID int64) bool {
|
|
if m.selectVisibleClusterID(clusterID) {
|
|
return true
|
|
}
|
|
cluster, ok := m.clusterFromWorkingSet(clusterID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
m.search = ""
|
|
if m.minSize > cluster.MemberCount {
|
|
m.minSize = 1
|
|
}
|
|
if cluster.Status != "active" || cluster.ClosedAt != "" {
|
|
m.showClosed = true
|
|
}
|
|
if m.payload.Limit > 0 && len(m.allClusters) > m.payload.Limit {
|
|
m.payload.Limit = len(m.allClusters)
|
|
}
|
|
m.applyClusterFilters()
|
|
return m.selectVisibleClusterID(clusterID)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) selectVisibleClusterID(clusterID int64) bool {
|
|
for index, cluster := range m.payload.Clusters {
|
|
if cluster.ID == clusterID {
|
|
m.selected = index
|
|
m.loadSelectedCluster()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterFromWorkingSet(clusterID int64) (store.ClusterSummary, bool) {
|
|
for _, cluster := range m.allClusters {
|
|
if cluster.ID == clusterID {
|
|
return cluster, true
|
|
}
|
|
}
|
|
return store.ClusterSummary{}, false
|
|
}
|
|
|
|
func (m *clusterBrowserModel) selectMemberByNumber(number int) bool {
|
|
for index, row := range m.memberRows {
|
|
if row.selectable && row.member.Thread.Number == number {
|
|
m.memberIndex = index
|
|
m.detailView.GotoTop()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *clusterBrowserModel) refreshFromStore() {
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Refresh unavailable for this view"
|
|
return
|
|
}
|
|
clusters, err := m.loadClusterSummariesFromStore()
|
|
if err != nil {
|
|
m.status = "Refresh failed: " + err.Error()
|
|
return
|
|
}
|
|
relaxedFilters := m.applyClusterRefresh(clusters, m.currentClusterID())
|
|
m.status = fmt.Sprintf("Refreshed %d cluster(s)", len(m.payload.Clusters))
|
|
if relaxedFilters {
|
|
m.status += " (filters relaxed)"
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) autoRefreshCmd() tea.Cmd {
|
|
if m.store == nil || m.repoID == 0 {
|
|
return nil
|
|
}
|
|
return tea.Tick(tuiAutoRefreshInterval, func(time.Time) tea.Msg {
|
|
return tuiAutoRefreshMsg{}
|
|
})
|
|
}
|
|
|
|
func (m *clusterBrowserModel) autoRefreshFromStore() {
|
|
if m.store == nil || m.repoID == 0 {
|
|
m.status = "Refresh unavailable for this view"
|
|
return
|
|
}
|
|
clusters, err := m.loadClusterSummariesFromStore()
|
|
if err != nil {
|
|
m.status = "Refresh failed: " + err.Error()
|
|
return
|
|
}
|
|
if clusterSummariesSignature(clusters) == m.clusterSignature() {
|
|
return
|
|
}
|
|
m.applyClusterRefresh(clusters, m.currentClusterID())
|
|
m.status = fmt.Sprintf("Auto refreshed %d cluster(s)", len(m.payload.Clusters))
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterSignature() string {
|
|
return clusterSummariesSignature(m.payload.Clusters)
|
|
}
|
|
|
|
func clusterSummariesSignature(clusters []store.ClusterSummary) string {
|
|
if len(clusters) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(clusters))
|
|
for _, cluster := range clusters {
|
|
parts = append(parts, fmt.Sprintf("%d:%d:%s", cluster.ID, cluster.MemberCount, cluster.UpdatedAt))
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func (m clusterBrowserModel) currentClusterID() int64 {
|
|
if len(m.payload.Clusters) == 0 || m.selected < 0 || m.selected >= len(m.payload.Clusters) {
|
|
return 0
|
|
}
|
|
return m.payload.Clusters[m.selected].ID
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterRefreshLimit() int {
|
|
if m.payload.Limit > 0 {
|
|
return m.payload.Limit
|
|
}
|
|
return maxInt(defaultTUIWorkingSetLimit, maxInt(len(m.payload.Clusters), len(m.allClusters)))
|
|
}
|
|
|
|
func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSummary, error) {
|
|
viewLimit := m.clusterRefreshLimit()
|
|
clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
|
RepoID: m.repoID,
|
|
IncludeClosed: m.showClosed,
|
|
MinSize: m.minSize,
|
|
Limit: viewLimit,
|
|
Sort: m.payload.Sort,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
workingSet, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
|
RepoID: m.repoID,
|
|
IncludeClosed: m.showClosed,
|
|
MinSize: 1,
|
|
Limit: viewLimit,
|
|
Sort: m.payload.Sort,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return mergeClusterSummaries(clusters, workingSet), nil
|
|
}
|
|
|
|
func (m *clusterBrowserModel) applyClusterRefresh(clusters []store.ClusterSummary, currentID int64) bool {
|
|
if clusters == nil {
|
|
clusters = []store.ClusterSummary{}
|
|
}
|
|
if m.payload.Limit <= 0 && len(clusters) > 0 && len(clusters) < len(m.allClusters) {
|
|
clusters = mergeClusterSummaries(clusters, m.allClusters)
|
|
}
|
|
m.detailCache = map[int64]store.ClusterDetail{}
|
|
m.allClusters = append([]store.ClusterSummary(nil), clusters...)
|
|
m.payload.Clusters = append([]store.ClusterSummary(nil), clusters...)
|
|
m.applyClusterFilters()
|
|
relaxedFilters := m.relaxFiltersIfEmpty()
|
|
if currentID != 0 {
|
|
for index, cluster := range m.payload.Clusters {
|
|
if cluster.ID == currentID {
|
|
m.selected = index
|
|
m.loadSelectedCluster()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return relaxedFilters
|
|
}
|
|
|
|
func (m *clusterBrowserModel) switchRepository(fullName string) {
|
|
if m.store == nil {
|
|
m.status = "Repository picker unavailable for this view"
|
|
return
|
|
}
|
|
fullName = strings.TrimSpace(fullName)
|
|
if fullName == "" {
|
|
m.status = "No repository selected"
|
|
return
|
|
}
|
|
repo, err := m.store.RepositoryByFullName(m.ctx, fullName)
|
|
if err != nil {
|
|
m.status = "Repository switch failed: " + err.Error()
|
|
return
|
|
}
|
|
clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
|
RepoID: repo.ID,
|
|
IncludeClosed: m.showClosed,
|
|
MinSize: m.minSize,
|
|
Limit: maxInt(20, m.payload.Limit),
|
|
Sort: m.payload.Sort,
|
|
})
|
|
if err != nil {
|
|
m.status = "Repository switch failed: " + err.Error()
|
|
return
|
|
}
|
|
workingSet, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
|
RepoID: repo.ID,
|
|
IncludeClosed: m.showClosed,
|
|
MinSize: 1,
|
|
Limit: maxInt(defaultTUIWorkingSetLimit, m.payload.Limit),
|
|
Sort: m.payload.Sort,
|
|
})
|
|
if err != nil {
|
|
m.status = "Repository switch failed: " + err.Error()
|
|
return
|
|
}
|
|
clusters = mergeClusterSummaries(clusters, workingSet)
|
|
if clusters == nil {
|
|
clusters = []store.ClusterSummary{}
|
|
}
|
|
m.repoID = repo.ID
|
|
m.payload.Repository = repo.FullName
|
|
m.payload.InferredRepository = false
|
|
m.detailCache = map[int64]store.ClusterDetail{}
|
|
m.neighborCache = map[int64][]tuiNeighbor{}
|
|
m.allClusters = append([]store.ClusterSummary(nil), clusters...)
|
|
m.payload.Clusters = append([]store.ClusterSummary(nil), clusters...)
|
|
m.search = ""
|
|
m.searchInput.SetValue("")
|
|
m.selected = 0
|
|
m.clusterOff = 0
|
|
m.memberOff = 0
|
|
m.memberIndex = -1
|
|
m.hasDetail = false
|
|
m.detail = store.ClusterDetail{}
|
|
m.applyClusterFilters()
|
|
relaxedFilters := m.relaxFiltersIfEmpty()
|
|
m.focus = focusClusters
|
|
m.status = "Repository: " + repo.FullName
|
|
if relaxedFilters {
|
|
m.status += " (filters relaxed)"
|
|
}
|
|
}
|
|
|
|
func (m *clusterBrowserModel) relaxFiltersIfEmpty() bool {
|
|
if len(m.payload.Clusters) > 0 || len(m.allClusters) == 0 {
|
|
return false
|
|
}
|
|
m.showClosed = true
|
|
m.minSize = 1
|
|
m.applyClusterFilters()
|
|
return len(m.payload.Clusters) > 0
|
|
}
|
|
|
|
func (m *clusterBrowserModel) applyClusterFilters() {
|
|
currentID := int64(0)
|
|
if len(m.payload.Clusters) > 0 && m.selected >= 0 && m.selected < len(m.payload.Clusters) {
|
|
currentID = m.payload.Clusters[m.selected].ID
|
|
}
|
|
query := strings.ToLower(strings.TrimSpace(m.search))
|
|
next := make([]store.ClusterSummary, 0, len(m.allClusters))
|
|
for _, cluster := range m.allClusters {
|
|
if !m.showClosed && (cluster.Status != "active" || cluster.ClosedAt != "") {
|
|
continue
|
|
}
|
|
if cluster.MemberCount < m.minSize {
|
|
continue
|
|
}
|
|
if query != "" && !strings.Contains(strings.ToLower(cluster.StableSlug+" "+cluster.Title+" "+cluster.RepresentativeTitle+" "+cluster.RepresentativeKind), query) {
|
|
continue
|
|
}
|
|
next = append(next, cluster)
|
|
}
|
|
m.payload.Clusters = next
|
|
m.sortClusters()
|
|
if m.payload.Limit > 0 && len(m.payload.Clusters) > m.payload.Limit {
|
|
m.payload.Clusters = m.payload.Clusters[:m.payload.Limit]
|
|
}
|
|
m.selected = 0
|
|
if currentID != 0 {
|
|
for index, cluster := range m.payload.Clusters {
|
|
if cluster.ID == currentID {
|
|
m.selected = index
|
|
break
|
|
}
|
|
}
|
|
}
|
|
m.clusterOff = 0
|
|
m.loadSelectedCluster()
|
|
}
|
|
|
|
func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) {
|
|
columns := memberColumns(maxInt(24, m.layout().members.w-4), m.memberSort)
|
|
switch {
|
|
case relativeX < columnRightEdge(columns, 0):
|
|
m.memberSort = memberSortNumber
|
|
case relativeX < columnRightEdge(columns, 1):
|
|
m.memberSort = memberSortState
|
|
case relativeX < columnRightEdge(columns, 2):
|
|
if m.memberSort == memberSortRecent {
|
|
m.memberSort = memberSortOldest
|
|
} else {
|
|
m.memberSort = memberSortRecent
|
|
}
|
|
default:
|
|
if m.memberSort == memberSortTitle {
|
|
m.memberSort = memberSortKind
|
|
} else {
|
|
m.memberSort = memberSortTitle
|
|
}
|
|
}
|
|
m.sortMembers()
|
|
m.status = "Member sort: " + string(m.memberSort)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) loadSelectedCluster() {
|
|
m.detailView.GotoTop()
|
|
m.memberOff = 0
|
|
m.memberIndex = -1
|
|
m.memberRows = nil
|
|
m.hasDetail = false
|
|
if len(m.payload.Clusters) == 0 {
|
|
return
|
|
}
|
|
cluster := m.payload.Clusters[m.selected]
|
|
if cached, ok := m.detailCache[cluster.ID]; ok {
|
|
m.applyClusterDetail(cached)
|
|
return
|
|
}
|
|
if m.store == nil {
|
|
return
|
|
}
|
|
detail, err := m.store.ClusterDetail(m.ctx, store.ClusterDetailOptions{
|
|
RepoID: m.repoID,
|
|
ClusterID: cluster.ID,
|
|
IncludeClosed: true,
|
|
MemberLimit: 200,
|
|
BodyChars: 1600,
|
|
})
|
|
if err != nil {
|
|
m.status = err.Error()
|
|
return
|
|
}
|
|
m.detailCache[cluster.ID] = detail
|
|
m.applyClusterDetail(detail)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) applyClusterDetail(detail store.ClusterDetail) {
|
|
m.detail = detail
|
|
m.hasDetail = true
|
|
m.sortMembers()
|
|
}
|
|
|
|
func (m *clusterBrowserModel) sortMembers() {
|
|
selectedID := int64(0)
|
|
if member, ok := m.selectedMember(); ok {
|
|
selectedID = member.Thread.ID
|
|
}
|
|
members := make([]store.ClusterMemberDetail, 0, len(m.detail.Members))
|
|
for _, member := range m.detail.Members {
|
|
if !memberVisible(member, m.showClosed) {
|
|
continue
|
|
}
|
|
members = append(members, member)
|
|
}
|
|
sort.SliceStable(members, func(i, j int) bool {
|
|
left := members[i].Thread
|
|
right := members[j].Thread
|
|
switch m.memberSort {
|
|
case memberSortRecent:
|
|
return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub))
|
|
case memberSortOldest:
|
|
return parseTime(left.UpdatedAtGitHub).Before(parseTime(right.UpdatedAtGitHub))
|
|
case memberSortNumber:
|
|
return left.Number < right.Number
|
|
case memberSortState:
|
|
if left.State != right.State {
|
|
return left.State > right.State
|
|
}
|
|
return left.Number < right.Number
|
|
case memberSortTitle:
|
|
return strings.ToLower(left.Title) < strings.ToLower(right.Title)
|
|
default:
|
|
if left.Kind != right.Kind {
|
|
return left.Kind < right.Kind
|
|
}
|
|
return left.Number < right.Number
|
|
}
|
|
})
|
|
m.memberRows = m.buildMemberRows(members)
|
|
m.memberIndex = m.firstSelectableMemberIndex()
|
|
if selectedID != 0 {
|
|
for index, row := range m.memberRows {
|
|
if row.selectable && row.member.Thread.ID == selectedID {
|
|
m.memberIndex = index
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m clusterBrowserModel) buildMemberRows(members []store.ClusterMemberDetail) []memberRow {
|
|
if m.memberSort != memberSortKind {
|
|
rows := make([]memberRow, 0, len(members))
|
|
for _, member := range members {
|
|
rows = append(rows, memberRow{member: member, selectable: true})
|
|
}
|
|
return rows
|
|
}
|
|
issues := make([]store.ClusterMemberDetail, 0, len(members))
|
|
pulls := make([]store.ClusterMemberDetail, 0, len(members))
|
|
other := make([]store.ClusterMemberDetail, 0)
|
|
for _, member := range members {
|
|
switch member.Thread.Kind {
|
|
case "issue":
|
|
issues = append(issues, member)
|
|
case "pull_request":
|
|
pulls = append(pulls, member)
|
|
default:
|
|
other = append(other, member)
|
|
}
|
|
}
|
|
rows := make([]memberRow, 0, len(members)+3)
|
|
appendGroup := func(label string, group []store.ClusterMemberDetail) {
|
|
if len(group) == 0 {
|
|
return
|
|
}
|
|
rows = append(rows, memberRow{label: fmt.Sprintf("%s (%d)", label, len(group))})
|
|
for _, member := range group {
|
|
rows = append(rows, memberRow{member: member, selectable: true})
|
|
}
|
|
}
|
|
appendGroup("ISSUES", issues)
|
|
appendGroup("PULL REQUESTS", pulls)
|
|
appendGroup("OTHER", other)
|
|
return rows
|
|
}
|
|
|
|
func (m clusterBrowserModel) firstSelectableMemberIndex() int {
|
|
for index, row := range m.memberRows {
|
|
if row.selectable {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (m clusterBrowserModel) lastSelectableMemberIndex() int {
|
|
for index := len(m.memberRows) - 1; index >= 0; index-- {
|
|
if m.memberRows[index].selectable {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (m clusterBrowserModel) nextSelectableMemberIndex(current, delta int) int {
|
|
if len(m.memberRows) == 0 {
|
|
return -1
|
|
}
|
|
step := 1
|
|
if delta < 0 {
|
|
step = -1
|
|
}
|
|
steps := maxInt(1, absInt(delta))
|
|
if current < 0 || current >= len(m.memberRows) || !m.memberRows[current].selectable {
|
|
if step < 0 {
|
|
return m.lastSelectableMemberIndex()
|
|
}
|
|
return m.firstSelectableMemberIndex()
|
|
}
|
|
index := current
|
|
for moved := 0; moved < steps; moved++ {
|
|
next := index + step
|
|
for next >= 0 && next < len(m.memberRows) && !m.memberRows[next].selectable {
|
|
next += step
|
|
}
|
|
if next < 0 || next >= len(m.memberRows) {
|
|
return index
|
|
}
|
|
index = next
|
|
}
|
|
return index
|
|
}
|
|
|
|
func (m clusterBrowserModel) openCounts() struct{ pulls, issues int } {
|
|
var out struct{ pulls, issues int }
|
|
for _, cluster := range m.payload.Clusters {
|
|
switch cluster.RepresentativeKind {
|
|
case "pull_request":
|
|
out.pulls++
|
|
case "issue":
|
|
out.issues++
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectableMemberCount() int {
|
|
count := 0
|
|
for _, row := range m.memberRows {
|
|
if row.selectable {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterPositionLabel() string {
|
|
total := len(m.payload.Clusters)
|
|
if total == 0 {
|
|
return "0"
|
|
}
|
|
return fmt.Sprintf("%d/%d", clampInt(m.selected+1, 1, total), total)
|
|
}
|
|
|
|
func (m clusterBrowserModel) memberPositionLabel() string {
|
|
total := m.selectableMemberCount()
|
|
if total == 0 {
|
|
return "0"
|
|
}
|
|
position := 0
|
|
for _, row := range m.memberRows[:clampInt(m.memberIndex+1, 0, len(m.memberRows))] {
|
|
if row.selectable {
|
|
position++
|
|
}
|
|
}
|
|
if position == 0 {
|
|
position = 1
|
|
}
|
|
return fmt.Sprintf("%d/%d", position, total)
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectedThread() (store.Thread, bool) {
|
|
if len(m.memberRows) == 0 || m.memberIndex < 0 || m.memberIndex >= len(m.memberRows) {
|
|
return store.Thread{}, false
|
|
}
|
|
if !m.memberRows[m.memberIndex].selectable {
|
|
return store.Thread{}, false
|
|
}
|
|
thread := m.memberRows[m.memberIndex].thread()
|
|
if strings.TrimSpace(thread.HTMLURL) == "" {
|
|
return store.Thread{}, false
|
|
}
|
|
return thread, true
|
|
}
|
|
|
|
func (m clusterBrowserModel) hasSelectedCluster() bool {
|
|
return len(m.payload.Clusters) > 0 && m.selected >= 0 && m.selected < len(m.payload.Clusters)
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectedCluster() (store.ClusterSummary, bool) {
|
|
if !m.hasSelectedCluster() {
|
|
return store.ClusterSummary{}, false
|
|
}
|
|
return m.payload.Clusters[m.selected], true
|
|
}
|
|
|
|
func clusterSupportsDurableLocalActions(cluster store.ClusterSummary) bool {
|
|
return cluster.Source == "" || cluster.Source == store.ClusterSourceDurable
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectedClusterURL() (string, bool) {
|
|
cluster, ok := m.selectedCluster()
|
|
if !ok || cluster.RepresentativeNumber <= 0 || strings.TrimSpace(m.payload.Repository) == "" {
|
|
return "", false
|
|
}
|
|
path := "issues"
|
|
if cluster.RepresentativeKind == "pull_request" {
|
|
path = "pull"
|
|
}
|
|
return fmt.Sprintf("https://github.com/%s/%s/%d", m.payload.Repository, path, cluster.RepresentativeNumber), true
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectedActionURL() (string, bool) {
|
|
if thread, ok := m.selectedThread(); ok {
|
|
return thread.HTMLURL, true
|
|
}
|
|
return m.selectedClusterURL()
|
|
}
|
|
|
|
func (m clusterBrowserModel) selectedMember() (store.ClusterMemberDetail, bool) {
|
|
if len(m.memberRows) == 0 || m.memberIndex < 0 || m.memberIndex >= len(m.memberRows) {
|
|
return store.ClusterMemberDetail{}, false
|
|
}
|
|
if !m.memberRows[m.memberIndex].selectable {
|
|
return store.ClusterMemberDetail{}, false
|
|
}
|
|
return m.memberRows[m.memberIndex].member, true
|
|
}
|
|
|
|
func (m clusterBrowserModel) firstReferenceLink() (string, bool) {
|
|
links := m.referenceLinks()
|
|
if len(links) > 0 {
|
|
return links[0], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (m clusterBrowserModel) referenceLinks() []string {
|
|
member, ok := m.selectedMember()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
links := make([]string, 0, 4)
|
|
seen := map[string]bool{}
|
|
for _, value := range append([]string{member.BodySnippet}, sortedSummaryValues(member.Summaries)...) {
|
|
for _, link := range markdownLinks(value) {
|
|
if !seen[link] {
|
|
links = append(links, link)
|
|
seen[link] = true
|
|
}
|
|
}
|
|
}
|
|
return links
|
|
}
|
|
|
|
func (m clusterBrowserModel) threadDetailClipboardText() string {
|
|
member, ok := m.selectedMember()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
thread := member.Thread
|
|
lines := []string{
|
|
fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title),
|
|
"State: " + memberDisplayState(member),
|
|
"Author: " + firstNonEmpty(thread.AuthorLogin, "unknown"),
|
|
"Updated: " + firstNonEmpty(thread.UpdatedAtGitHub, thread.UpdatedAt, "unknown"),
|
|
"URL: " + thread.HTMLURL,
|
|
}
|
|
if summaries := summariesClipboardText(member.Summaries); summaries != "" {
|
|
lines = append(lines, "", "Summaries", summaries)
|
|
}
|
|
if strings.TrimSpace(member.BodySnippet) != "" {
|
|
lines = append(lines, "", "Body preview", member.BodySnippet)
|
|
}
|
|
if links := m.referenceLinks(); len(links) > 0 {
|
|
lines = append(lines, "", "Links", strings.Join(links, "\n"))
|
|
}
|
|
if neighbors := m.neighborsClipboardText(); neighbors != "" {
|
|
lines = append(lines, "", "Neighbors")
|
|
lines = append(lines, neighbors)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m clusterBrowserModel) summariesClipboardText() string {
|
|
member, ok := m.selectedMember()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return summariesClipboardText(member.Summaries)
|
|
}
|
|
|
|
func summariesClipboardText(summaries map[string]string) string {
|
|
if len(summaries) == 0 {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, len(summaries)*2)
|
|
for _, key := range sortedSummaryKeys(summaries) {
|
|
lines = append(lines, formatSummaryLabel(key)+":", summaries[key], "")
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (m clusterBrowserModel) neighborsClipboardText() string {
|
|
member, ok := m.selectedMember()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
neighbors, ok := m.neighborCache[member.Thread.ID]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if len(neighbors) == 0 {
|
|
return "No neighbors above threshold."
|
|
}
|
|
lines := make([]string, 0, len(neighbors))
|
|
for _, neighbor := range neighbors {
|
|
lines = append(lines, fmt.Sprintf("#%d %s %.1f%% %s",
|
|
neighbor.Thread.Number,
|
|
kindTitle(neighbor.Thread.Kind),
|
|
neighbor.Score*100,
|
|
neighbor.Thread.Title,
|
|
))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m clusterBrowserModel) clusterClipboardText() string {
|
|
if len(m.payload.Clusters) == 0 || m.selected < 0 || m.selected >= len(m.payload.Clusters) {
|
|
return ""
|
|
}
|
|
cluster := m.payload.Clusters[m.selected]
|
|
lines := []string{
|
|
fmt.Sprintf("Cluster %d", cluster.ID),
|
|
"Name: " + cluster.StableSlug,
|
|
"Title: " + firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster"),
|
|
fmt.Sprintf("State: %s", firstNonEmpty(cluster.Status, "unknown")),
|
|
fmt.Sprintf("Members: %d", cluster.MemberCount),
|
|
"Updated: " + firstNonEmpty(cluster.UpdatedAt, "unknown"),
|
|
"Representative: " + threadRef(cluster),
|
|
}
|
|
if member, ok := m.selectedMember(); ok {
|
|
thread := member.Thread
|
|
lines = append(lines, "", fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title), thread.HTMLURL)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m clusterBrowserModel) visibleClustersClipboardText() string {
|
|
if len(m.payload.Clusters) == 0 {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, len(m.payload.Clusters))
|
|
for _, cluster := range m.payload.Clusters {
|
|
lines = append(lines, fmt.Sprintf(
|
|
"C%d [%s] %d items %s - %s (%s)",
|
|
cluster.ID,
|
|
firstNonEmpty(cluster.Status, "unknown"),
|
|
cluster.MemberCount,
|
|
cluster.StableSlug,
|
|
firstNonEmpty(cluster.RepresentativeTitle, cluster.Title, "Untitled cluster"),
|
|
threadRef(cluster),
|
|
))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m clusterBrowserModel) memberListClipboardText() string {
|
|
if len(m.memberRows) == 0 {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, len(m.memberRows))
|
|
for _, row := range m.memberRows {
|
|
if !row.selectable {
|
|
continue
|
|
}
|
|
thread := row.thread()
|
|
lines = append(lines, fmt.Sprintf("#%d [%s] %s %s %s",
|
|
thread.Number,
|
|
memberDisplayState(row.member),
|
|
kindTitle(thread.Kind),
|
|
thread.Title,
|
|
thread.HTMLURL,
|
|
))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (r memberRow) thread() store.Thread {
|
|
return r.member.Thread
|
|
}
|
|
|
|
var openURL = func(url string) error {
|
|
if strings.TrimSpace(url) == "" {
|
|
return fmt.Errorf("no URL selected")
|
|
}
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
cmd = exec.Command("open", url)
|
|
case "windows":
|
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
|
default:
|
|
cmd = exec.Command("xdg-open", url)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("open URL: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyText(value string) error {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fmt.Errorf("nothing to copy")
|
|
}
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
cmd = exec.Command("pbcopy")
|
|
case "windows":
|
|
cmd = exec.Command("clip")
|
|
default:
|
|
cmd = exec.Command("xclip", "-selection", "clipboard")
|
|
}
|
|
cmd.Stdin = strings.NewReader(value)
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("copy text: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func paneStyle(pane, focus tuiFocus, width, height int) lipgloss.Style {
|
|
borderColor := "#4a5568"
|
|
switch pane {
|
|
case focusClusters:
|
|
borderColor = "#5bc0eb"
|
|
case focusMembers:
|
|
borderColor = "#9bc53d"
|
|
case focusDetail:
|
|
borderColor = "#fde74c"
|
|
}
|
|
if pane == focus {
|
|
borderColor = "#f7f7ff"
|
|
}
|
|
return lipgloss.NewStyle().
|
|
Width(width-2).
|
|
Height(height-2).
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color(borderColor)).
|
|
Foreground(lipgloss.Color("#dfe7ef")).
|
|
Padding(0, 1)
|
|
}
|
|
|
|
func paneTitle(pane, focus tuiFocus, suffix string) string {
|
|
label := map[tuiFocus]string{
|
|
focusClusters: "Clusters",
|
|
focusMembers: "Members",
|
|
focusDetail: "Detail",
|
|
}[pane]
|
|
if strings.TrimSpace(suffix) != "" {
|
|
label += " " + suffix
|
|
}
|
|
prefix := "[ ] "
|
|
if pane == focus {
|
|
prefix = "[*] "
|
|
}
|
|
return bold(prefix + label)
|
|
}
|
|
|
|
func nextFocus(current tuiFocus, delta int) tuiFocus {
|
|
order := []tuiFocus{focusClusters, focusMembers, focusDetail}
|
|
index := 0
|
|
for i, item := range order {
|
|
if item == current {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
index = (index + delta + len(order)) % len(order)
|
|
return order[index]
|
|
}
|
|
|
|
func nextMemberSort(current tuiMemberSort) tuiMemberSort {
|
|
order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortOldest, memberSortNumber, memberSortState, memberSortTitle}
|
|
for index, item := range order {
|
|
if item == current {
|
|
return order[(index+1)%len(order)]
|
|
}
|
|
}
|
|
return memberSortKind
|
|
}
|
|
|
|
func (m *clusterBrowserModel) toggleWideLayout() {
|
|
if m.wideLayout == wideLayoutColumns {
|
|
m.wideLayout = wideLayoutRightStack
|
|
} else {
|
|
m.wideLayout = wideLayoutColumns
|
|
}
|
|
m.status = "Layout: " + string(m.wideLayout)
|
|
}
|
|
|
|
func (m *clusterBrowserModel) toggleDetailMode() {
|
|
m.compactDetail = !m.compactDetail
|
|
if m.compactDetail {
|
|
m.status = "Detail mode: compact"
|
|
return
|
|
}
|
|
m.status = "Detail mode: full"
|
|
}
|
|
|
|
func nextMinSize(current int) int {
|
|
order := []int{1, 2, 5, 10, 20, 50}
|
|
for index, item := range order {
|
|
if item == current {
|
|
return order[(index+1)%len(order)]
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func minSizeLabel(value int) string {
|
|
if value <= 1 {
|
|
return "all"
|
|
}
|
|
return fmt.Sprintf("%d+", value)
|
|
}
|
|
|
|
func boolLabel(value bool) string {
|
|
if value {
|
|
return "shown"
|
|
}
|
|
return "hidden"
|
|
}
|
|
|
|
func closedToggleLabel(showClosed bool) string {
|
|
if showClosed {
|
|
return "Hide closed"
|
|
}
|
|
return "Show closed"
|
|
}
|
|
|
|
func detailModeToggleLabel(compact bool) string {
|
|
if compact {
|
|
return "Show full detail"
|
|
}
|
|
return "Show compact detail"
|
|
}
|
|
|
|
func detailModeLabel(compact bool) string {
|
|
if compact {
|
|
return "compact"
|
|
}
|
|
return "full"
|
|
}
|
|
|
|
func layoutLabel(layout tuiLayout) string {
|
|
if layout.mode != "" {
|
|
return layout.mode
|
|
}
|
|
if layout.stacked {
|
|
return "stacked"
|
|
}
|
|
return string(wideLayoutColumns)
|
|
}
|
|
|
|
func splitClusterTitle(cluster store.ClusterSummary) string {
|
|
return firstNonEmpty(renderTitleText(cluster.RepresentativeTitle), renderTitleText(cluster.Title), "Untitled cluster")
|
|
}
|
|
|
|
func renderTitleText(value string) string {
|
|
value = strings.TrimSpace(stripEmoji(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func stripEmoji(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(value))
|
|
for _, r := range value {
|
|
if isEmojiRune(r) {
|
|
continue
|
|
}
|
|
out.WriteRune(r)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func isEmojiRune(r rune) bool {
|
|
switch {
|
|
case r == '\u200d' || r == '\u20e3':
|
|
return true
|
|
case r >= '\ufe00' && r <= '\ufe0f':
|
|
return true
|
|
case r >= '\U0001f000' && r <= '\U0001faff':
|
|
return true
|
|
case r >= '\u2600' && r <= '\u27bf':
|
|
return true
|
|
case r == '\u3030' || r == '\u303d' || r == '\u3297' || r == '\u3299':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sortedSummaryKeys(values map[string]string) []string {
|
|
keys := make([]string, 0, len(values))
|
|
seen := map[string]bool{}
|
|
for _, key := range summaryKeyOrder {
|
|
if strings.TrimSpace(values[key]) != "" {
|
|
keys = append(keys, key)
|
|
seen[key] = true
|
|
}
|
|
}
|
|
var extra []string
|
|
for key, value := range values {
|
|
if !seen[key] && strings.TrimSpace(value) != "" {
|
|
extra = append(extra, key)
|
|
}
|
|
}
|
|
sort.Strings(extra)
|
|
keys = append(keys, extra...)
|
|
return keys
|
|
}
|
|
|
|
func sortedSummaryValues(values map[string]string) []string {
|
|
keys := sortedSummaryKeys(values)
|
|
out := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
out = append(out, values[key])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatSummaryLabel(key string) string {
|
|
switch key {
|
|
case "key_summary":
|
|
return "Key summary"
|
|
case "problem_summary":
|
|
return "Purpose"
|
|
case "solution_summary":
|
|
return "Solution"
|
|
case "maintainer_signal_summary":
|
|
return "Maintainer signal"
|
|
case "dedupe_summary":
|
|
return "Cluster signal"
|
|
default:
|
|
return strings.ReplaceAll(key, "_", " ")
|
|
}
|
|
}
|
|
|
|
func labelsFromJSON(raw string) string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return ""
|
|
}
|
|
var labels []struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &labels); err == nil && len(labels) > 0 {
|
|
names := make([]string, 0, len(labels))
|
|
for _, label := range labels {
|
|
if strings.TrimSpace(label.Name) != "" {
|
|
names = append(names, label.Name)
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
return strings.Join(names, ", ")
|
|
}
|
|
}
|
|
var names []string
|
|
if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 {
|
|
return strings.Join(names, ", ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func kindLabel(kind string) string {
|
|
if kind == "pull_request" {
|
|
return "PR"
|
|
}
|
|
if kind == "issue" {
|
|
return "issue"
|
|
}
|
|
return firstNonEmpty(kind, "thread")
|
|
}
|
|
|
|
func kindGlyph(kind string) string {
|
|
if kind == "pull_request" {
|
|
return "PR"
|
|
}
|
|
if kind == "issue" {
|
|
return "I"
|
|
}
|
|
return truncateCells(firstNonEmpty(kind, "?"), 2)
|
|
}
|
|
|
|
func clusterStateLabel(cluster store.ClusterSummary) string {
|
|
switch strings.ToLower(firstNonEmpty(cluster.Status, "active")) {
|
|
case "closed":
|
|
return "CLOSED"
|
|
case "merged":
|
|
return "MERGED"
|
|
case "split":
|
|
return "SPLIT"
|
|
default:
|
|
if cluster.ClosedAt != "" {
|
|
return "CLOSED"
|
|
}
|
|
return "OPEN"
|
|
}
|
|
}
|
|
|
|
func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool) lipgloss.Style {
|
|
status := strings.ToLower(firstNonEmpty(cluster.Status, "active"))
|
|
if cluster.ClosedAt != "" && status == "active" {
|
|
status = "closed"
|
|
}
|
|
switch status {
|
|
case "closed":
|
|
if selected {
|
|
return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG)
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG))
|
|
case "merged", "split":
|
|
if selected {
|
|
return selectedRowStyle(focused, "#394052", "#d8c4ff", "#242936", "#b8a3d8")
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#b8a3d8")).Background(lipgloss.Color("#151620"))
|
|
default:
|
|
if selected {
|
|
return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG)
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG))
|
|
}
|
|
}
|
|
|
|
func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style {
|
|
if !row.selectable {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiMutedAccent)).Bold(true)
|
|
}
|
|
state := strings.ToLower(memberDisplayState(row.member))
|
|
switch state {
|
|
case "closed", "local", "merged":
|
|
if selected {
|
|
return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG)
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG))
|
|
default:
|
|
if selected {
|
|
return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG)
|
|
}
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG))
|
|
}
|
|
}
|
|
|
|
func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style {
|
|
style := lipgloss.NewStyle()
|
|
if focused {
|
|
return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG))
|
|
}
|
|
return style.Foreground(lipgloss.Color(blurredFG)).Background(lipgloss.Color(blurredBG))
|
|
}
|
|
|
|
func kindTitle(kind string) string {
|
|
if kind == "pull_request" {
|
|
return "PR"
|
|
}
|
|
return "Issue"
|
|
}
|
|
|
|
func stateGlyph(state string) string {
|
|
switch state {
|
|
case "open":
|
|
return "opn"
|
|
case "closed":
|
|
return "cls"
|
|
case "excluded":
|
|
return "exc"
|
|
case "local":
|
|
return "loc"
|
|
case "merged":
|
|
return "mrg"
|
|
default:
|
|
return truncateCells(firstNonEmpty(state, "?"), 3)
|
|
}
|
|
}
|
|
|
|
func threadDisplayState(thread store.Thread) string {
|
|
if thread.ClosedAtLocal != "" {
|
|
return "local"
|
|
}
|
|
return firstNonEmpty(thread.State, "unknown")
|
|
}
|
|
|
|
func threadVisible(thread store.Thread, showClosed bool) bool {
|
|
if showClosed {
|
|
return true
|
|
}
|
|
return thread.State == "open" && thread.ClosedAtLocal == ""
|
|
}
|
|
|
|
func memberDisplayState(member store.ClusterMemberDetail) string {
|
|
if member.State != "" && member.State != "active" {
|
|
return member.State
|
|
}
|
|
return threadDisplayState(member.Thread)
|
|
}
|
|
|
|
func memberVisible(member store.ClusterMemberDetail, showClosed bool) bool {
|
|
if showClosed {
|
|
return true
|
|
}
|
|
return (member.State == "" || member.State == "active") && threadVisible(member.Thread, false)
|
|
}
|
|
|
|
func closedLabel(thread store.Thread) string {
|
|
if thread.ClosedAtLocal == "" && thread.State == "open" {
|
|
return "no"
|
|
}
|
|
closedAt := firstNonEmpty(thread.ClosedAtLocal, thread.ClosedAtGitHub, thread.State)
|
|
if thread.CloseReasonLocal != "" {
|
|
return closedAt + " (" + thread.CloseReasonLocal + ")"
|
|
}
|
|
return closedAt
|
|
}
|
|
|
|
func tuiRule(width int) string {
|
|
return strings.Repeat("-", minInt(72, maxInt(12, width)))
|
|
}
|
|
|
|
func threadRef(cluster store.ClusterSummary) string {
|
|
if cluster.RepresentativeNumber == 0 {
|
|
return "none"
|
|
}
|
|
return fmt.Sprintf("%s #%d", kindLabel(cluster.RepresentativeKind), cluster.RepresentativeNumber)
|
|
}
|
|
|
|
func formatRelativeTime(value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "never"
|
|
}
|
|
parsed := parseTime(value)
|
|
if parsed.IsZero() {
|
|
return value
|
|
}
|
|
diff := time.Since(parsed)
|
|
if diff < time.Minute {
|
|
return "now"
|
|
}
|
|
if diff < time.Hour {
|
|
return fmt.Sprintf("%dm ago", int(diff/time.Minute))
|
|
}
|
|
if diff < 24*time.Hour {
|
|
return fmt.Sprintf("%dh ago", int(diff/time.Hour))
|
|
}
|
|
if diff < 60*24*time.Hour {
|
|
return fmt.Sprintf("%dd ago", int(diff/(24*time.Hour)))
|
|
}
|
|
return fmt.Sprintf("%dmo ago", maxInt(1, int(diff/(30*24*time.Hour))))
|
|
}
|
|
|
|
func parseTime(value string) time.Time {
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
|
parsed, err := time.Parse(layout, value)
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func wrapPlain(value string, width int) []string {
|
|
width = maxInt(20, width)
|
|
words := strings.Fields(value)
|
|
if len(words) == 0 {
|
|
return []string{""}
|
|
}
|
|
var lines []string
|
|
var line string
|
|
for _, word := range words {
|
|
if lipgloss.Width(word) > width {
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
line = ""
|
|
}
|
|
lines = append(lines, truncateCells(word, width))
|
|
continue
|
|
}
|
|
if lipgloss.Width(line)+1+lipgloss.Width(word) > width && line != "" {
|
|
lines = append(lines, line)
|
|
line = word
|
|
continue
|
|
}
|
|
if line == "" {
|
|
line = word
|
|
} else {
|
|
line += " " + word
|
|
}
|
|
}
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func markdownLines(value string, width int) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
width = maxInt(20, width)
|
|
var lines []string
|
|
inFence := false
|
|
blankRun := 0
|
|
for _, rawLine := range strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") {
|
|
line := strings.TrimRight(stripTerminalControls(rawLine), " \t")
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "```") {
|
|
inFence = !inFence
|
|
lines = append(lines, dim("--- code ---"))
|
|
blankRun = 0
|
|
continue
|
|
}
|
|
if inFence {
|
|
lines = append(lines, dim(truncateCells(line, width)))
|
|
blankRun = 0
|
|
continue
|
|
}
|
|
if trimmed == "" {
|
|
blankRun++
|
|
if blankRun <= 1 {
|
|
lines = append(lines, "")
|
|
}
|
|
continue
|
|
}
|
|
blankRun = 0
|
|
if match := markdownHeadingRE.FindStringSubmatch(trimmed); match != nil {
|
|
lines = appendWrappedStyled(lines, "", renderInlineMarkdown(match[2]), width, bold)
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, ">") {
|
|
quote := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
|
|
lines = appendWrappedStyled(lines, "> ", renderInlineMarkdown(quote), width, dim)
|
|
continue
|
|
}
|
|
if match := markdownListRE.FindStringSubmatch(line); match != nil {
|
|
indent := match[1]
|
|
if lipgloss.Width(indent) > 4 {
|
|
indent = strings.Repeat(" ", 4)
|
|
}
|
|
lines = appendWrappedStyled(lines, indent+"- ", renderInlineMarkdown(match[3]), width, nil)
|
|
continue
|
|
}
|
|
lines = appendWrappedStyled(lines, "", renderInlineMarkdown(line), width, nil)
|
|
}
|
|
return trimTrailingBlankLines(lines)
|
|
}
|
|
|
|
func appendWrappedStyled(lines []string, prefix, value string, width int, styler func(string) string) []string {
|
|
contentWidth := maxInt(8, width-lipgloss.Width(prefix))
|
|
wrapped := wrapPlain(value, contentWidth)
|
|
if len(wrapped) == 0 {
|
|
return lines
|
|
}
|
|
continuation := strings.Repeat(" ", lipgloss.Width(prefix))
|
|
for index, line := range wrapped {
|
|
prefixForLine := prefix
|
|
if index > 0 {
|
|
prefixForLine = continuation
|
|
}
|
|
if styler != nil {
|
|
line = styler(line)
|
|
}
|
|
lines = append(lines, prefixForLine+line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderInlineMarkdown(value string) string {
|
|
value = markdownLinkRE.ReplaceAllString(value, "$1 <$2>")
|
|
replacer := strings.NewReplacer(
|
|
"`", "",
|
|
"**", "",
|
|
"__", "",
|
|
"~~", "",
|
|
)
|
|
return strings.TrimSpace(replacer.Replace(value))
|
|
}
|
|
|
|
func firstMarkdownLink(value string) (string, bool) {
|
|
links := markdownLinks(value)
|
|
if len(links) == 0 {
|
|
return "", false
|
|
}
|
|
return links[0], true
|
|
}
|
|
|
|
func markdownLinks(value string) []string {
|
|
links := make([]string, 0, 2)
|
|
seen := map[string]bool{}
|
|
for _, match := range markdownLinkRE.FindAllStringSubmatch(value, -1) {
|
|
if len(match) > 2 {
|
|
link := stripTrailingURLPunctuation(match[2])
|
|
if !seen[link] {
|
|
links = append(links, link)
|
|
seen[link] = true
|
|
}
|
|
}
|
|
}
|
|
for _, match := range bareLinkRE.FindAllStringSubmatch(value, -1) {
|
|
if len(match) > 2 {
|
|
link := stripTrailingURLPunctuation(match[2])
|
|
if !seen[link] {
|
|
links = append(links, link)
|
|
seen[link] = true
|
|
}
|
|
}
|
|
}
|
|
return links
|
|
}
|
|
|
|
func formatLinkChoiceLabel(url string, index int) string {
|
|
return fmt.Sprintf("%2d %s", index+1, url)
|
|
}
|
|
|
|
func stripTrailingURLPunctuation(value string) string {
|
|
return strings.TrimRight(value, ".,;:!?")
|
|
}
|
|
|
|
func stripTerminalControls(value string) string {
|
|
return terminalControlRE.ReplaceAllString(value, "")
|
|
}
|
|
|
|
func trimTrailingBlankLines(lines []string) []string {
|
|
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m clusterBrowserModel) detailBodyLimit() int {
|
|
if m.compactDetail {
|
|
return 18
|
|
}
|
|
return 240
|
|
}
|
|
|
|
func appendLimitedLines(out, lines []string, limit int) []string {
|
|
if limit <= 0 || len(lines) <= limit {
|
|
return append(out, lines...)
|
|
}
|
|
omitted := len(lines) - limit
|
|
out = append(out, lines[:limit]...)
|
|
return append(out, dim(fmt.Sprintf("... %d more line(s). Press d for full detail.", omitted)))
|
|
}
|
|
|
|
func truncateCells(value string, max int) string {
|
|
if max <= 0 {
|
|
return ""
|
|
}
|
|
if lipgloss.Width(value) <= max {
|
|
return value
|
|
}
|
|
if max <= 3 {
|
|
return strings.Repeat(".", max)
|
|
}
|
|
runes := []rune(value)
|
|
for len(runes) > 0 && lipgloss.Width(string(runes))+3 > max {
|
|
runes = runes[:len(runes)-1]
|
|
}
|
|
return string(runes) + "..."
|
|
}
|
|
|
|
func bold(value string) string {
|
|
return lipgloss.NewStyle().Bold(true).Render(value)
|
|
}
|
|
|
|
func dim(value string) string {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#8b95a7")).Render(value)
|
|
}
|
|
|
|
func color(hex, value string) string {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Render(value)
|
|
}
|
|
|
|
func selectedColor(focused bool) string {
|
|
if focused {
|
|
return "#f7f7ff"
|
|
}
|
|
return "#23445c"
|
|
}
|
|
|
|
func selectedFG(focused bool) string {
|
|
if focused {
|
|
return "#05070d"
|
|
}
|
|
return "#f7f7ff"
|
|
}
|
|
|
|
func floatingMenuStyle(width, height int, palette actionMenuPalette) lipgloss.Style {
|
|
return lipgloss.NewStyle().
|
|
Width(maxInt(1, width-2)).
|
|
Height(maxInt(1, height-2)).
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color(palette.accent)).
|
|
Background(lipgloss.Color(palette.background)).
|
|
Foreground(lipgloss.Color(palette.foreground))
|
|
}
|
|
|
|
func selectedMenuLineStyle(width int, palette actionMenuPalette) lipgloss.Style {
|
|
return lipgloss.NewStyle().
|
|
Width(maxInt(1, width)).
|
|
Background(lipgloss.Color(palette.selectedBG)).
|
|
Foreground(lipgloss.Color(palette.selectedFG)).
|
|
Bold(true)
|
|
}
|
|
|
|
func overlayBlock(base, block string, x, y, width int) string {
|
|
baseLines := strings.Split(base, "\n")
|
|
blockLines := strings.Split(block, "\n")
|
|
for offset, line := range blockLines {
|
|
row := y + offset
|
|
if row < 0 || row >= len(baseLines) {
|
|
continue
|
|
}
|
|
baseLine := baseLines[row]
|
|
prefix := strings.Repeat(" ", maxInt(0, x))
|
|
if x > 0 && baseLine != "" {
|
|
prefix = padCells(ansi.Cut(baseLine, 0, x), x)
|
|
}
|
|
lineWidth := ansi.StringWidth(line)
|
|
suffixStart := maxInt(0, x+lineWidth)
|
|
suffix := ""
|
|
if suffixStart < ansi.StringWidth(baseLine) {
|
|
suffix = ansi.Cut(baseLine, suffixStart, width)
|
|
}
|
|
rendered := prefix + line + suffix
|
|
if width > 0 {
|
|
rendered = truncateCells(rendered, width)
|
|
}
|
|
baseLines[row] = rendered
|
|
}
|
|
return strings.Join(baseLines, "\n")
|
|
}
|
|
|
|
func padCells(value string, width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
cellWidth := ansi.StringWidth(value)
|
|
if cellWidth >= width {
|
|
return ansi.Cut(value, 0, width)
|
|
}
|
|
return value + strings.Repeat(" ", width-cellWidth)
|
|
}
|
|
|
|
func fitBlock(value string, width, height int) string {
|
|
width = maxInt(1, width)
|
|
height = maxInt(1, height)
|
|
lines := strings.Split(value, "\n")
|
|
if len(lines) > height {
|
|
lines = lines[:height]
|
|
}
|
|
for len(lines) < height {
|
|
lines = append(lines, "")
|
|
}
|
|
for index, line := range lines {
|
|
lines[index] = padCells(line, width)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func absInt(value int) int {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func clampInt(value, minValue, maxValue int) int {
|
|
if maxValue < minValue {
|
|
return minValue
|
|
}
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func columnLeftEdge(columns []table.Column, index int) int {
|
|
left := 0
|
|
for i := 0; i < index && i < len(columns); i++ {
|
|
left += columns[i].Width + 1
|
|
}
|
|
return left
|
|
}
|
|
|
|
func columnRightEdge(columns []table.Column, index int) int {
|
|
if index < 0 || index >= len(columns) {
|
|
return 0
|
|
}
|
|
return columnLeftEdge(columns, index) + columns[index].Width
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|