Compare commits
6 Commits
main
...
fix/tui-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e8b4b0c9 | ||
|
|
d722b934e6 | ||
|
|
ce8e8ed436 | ||
|
|
c4d04521b4 | ||
|
|
5a1158d9f8 | ||
|
|
f9cec5ed3f |
@ -976,7 +976,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string,
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
minSizeRaw := fs.String("min-size", "", "minimum active member count")
|
minSizeRaw := fs.String("min-size", "", "minimum active member count")
|
||||||
limitRaw := fs.String("limit", "", "maximum cluster rows")
|
limitRaw := fs.String("limit", "", "maximum cluster rows")
|
||||||
sortMode := fs.String("sort", "size", "sort mode: recent|size")
|
sortMode := fs.String("sort", "size", "sort mode: recent|oldest|size")
|
||||||
includeClosed := fs.Bool("include-closed", false, "deprecated; clusters include closed rows by default")
|
includeClosed := fs.Bool("include-closed", false, "deprecated; clusters include closed rows by default")
|
||||||
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
|
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
|
||||||
jsonOut := fs.Bool("json", false, "write JSON output")
|
jsonOut := fs.Bool("json", false, "write JSON output")
|
||||||
@ -1000,7 +1000,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string,
|
|||||||
return usageErr(err)
|
return usageErr(err)
|
||||||
}
|
}
|
||||||
sort := strings.TrimSpace(*sortMode)
|
sort := strings.TrimSpace(*sortMode)
|
||||||
if sort != "recent" && sort != "size" {
|
if sort != "recent" && sort != "oldest" && sort != "size" {
|
||||||
return usageErr(fmt.Errorf("unsupported sort %q", sort))
|
return usageErr(fmt.Errorf("unsupported sort %q", sort))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1040,7 +1040,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
minSizeRaw := fs.String("min-size", "", "minimum active member count")
|
minSizeRaw := fs.String("min-size", "", "minimum active member count")
|
||||||
limitRaw := fs.String("limit", "", "maximum cluster rows")
|
limitRaw := fs.String("limit", "", "maximum cluster rows")
|
||||||
sortMode := fs.String("sort", "", "sort mode: recent|size")
|
sortMode := fs.String("sort", "", "sort mode: recent|oldest|size")
|
||||||
includeClosed := fs.Bool("include-closed", false, "deprecated; closed clusters are shown by default")
|
includeClosed := fs.Bool("include-closed", false, "deprecated; closed clusters are shown by default")
|
||||||
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
|
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
|
||||||
jsonOut := fs.Bool("json", false, "write JSON output")
|
jsonOut := fs.Bool("json", false, "write JSON output")
|
||||||
@ -1090,7 +1090,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
|
|||||||
if sort == "" {
|
if sort == "" {
|
||||||
sort = "size"
|
sort = "size"
|
||||||
}
|
}
|
||||||
if sort != "recent" && sort != "size" {
|
if sort != "recent" && sort != "oldest" && sort != "size" {
|
||||||
return usageErr(fmt.Errorf("unsupported sort %q", sort))
|
return usageErr(fmt.Errorf("unsupported sort %q", sort))
|
||||||
}
|
}
|
||||||
showClosed := !*hideClosed || *includeClosed
|
showClosed := !*hideClosed || *includeClosed
|
||||||
@ -2707,7 +2707,7 @@ No API server is provided. There is intentionally no serve command.
|
|||||||
const tuiUsageText = `gitcrawl tui opens the local terminal cluster browser.
|
const tuiUsageText = `gitcrawl tui opens the local terminal cluster browser.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
gitcrawl tui [owner/repo] [--limit N] [--min-size N] [--sort recent|size] [--hide-closed]
|
gitcrawl tui [owner/repo] [--limit N] [--min-size N] [--sort recent|oldest|size] [--hide-closed]
|
||||||
|
|
||||||
If owner/repo is omitted, gitcrawl uses the most recently updated repository in the local database.
|
If owner/repo is omitted, gitcrawl uses the most recently updated repository in the local database.
|
||||||
The TUI starts with ghcrawl-style cluster display defaults: --min-size 5, --sort size, and closed historical clusters visible. Pass --min-size 1 for singleton clusters or --hide-closed to focus open-only.
|
The TUI starts with ghcrawl-style cluster display defaults: --min-size 5, --sort size, and closed historical clusters visible. Pass --min-size 1 for singleton clusters or --hide-closed to focus open-only.
|
||||||
|
|||||||
@ -34,10 +34,15 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const tuiAutoRefreshInterval = 15 * time.Second
|
const tuiAutoRefreshInterval = 15 * time.Second
|
||||||
|
const tuiWheelScrollDelay = 16 * time.Millisecond
|
||||||
|
const tuiWheelMaxBufferedDelta = 6
|
||||||
const tuiWheelSettleDelay = 90 * time.Millisecond
|
const tuiWheelSettleDelay = 90 * time.Millisecond
|
||||||
|
|
||||||
type tuiAutoRefreshMsg struct{}
|
type tuiAutoRefreshMsg struct{}
|
||||||
type tuiRemoteRefreshTickMsg struct{}
|
type tuiRemoteRefreshTickMsg struct{}
|
||||||
|
type tuiWheelScrollMsg struct {
|
||||||
|
seq int
|
||||||
|
}
|
||||||
type tuiWheelSettledMsg struct {
|
type tuiWheelSettledMsg struct {
|
||||||
seq int
|
seq int
|
||||||
}
|
}
|
||||||
@ -77,6 +82,7 @@ type tuiMemberSort string
|
|||||||
const (
|
const (
|
||||||
memberSortKind tuiMemberSort = "kind"
|
memberSortKind tuiMemberSort = "kind"
|
||||||
memberSortRecent tuiMemberSort = "recent"
|
memberSortRecent tuiMemberSort = "recent"
|
||||||
|
memberSortOldest tuiMemberSort = "oldest"
|
||||||
memberSortNumber tuiMemberSort = "number"
|
memberSortNumber tuiMemberSort = "number"
|
||||||
memberSortState tuiMemberSort = "state"
|
memberSortState tuiMemberSort = "state"
|
||||||
memberSortTitle tuiMemberSort = "title"
|
memberSortTitle tuiMemberSort = "title"
|
||||||
@ -113,6 +119,7 @@ type clusterBrowserModel struct {
|
|||||||
showHelp bool
|
showHelp bool
|
||||||
menuOpen bool
|
menuOpen bool
|
||||||
menuTitle string
|
menuTitle string
|
||||||
|
menuContext tuiFocus
|
||||||
menuIndex int
|
menuIndex int
|
||||||
menuOff int
|
menuOff int
|
||||||
menuItems []tuiMenuItem
|
menuItems []tuiMenuItem
|
||||||
@ -134,6 +141,10 @@ type clusterBrowserModel struct {
|
|||||||
lastClickX int
|
lastClickX int
|
||||||
lastClickY int
|
lastClickY int
|
||||||
lastClickAt time.Time
|
lastClickAt time.Time
|
||||||
|
wheelScrollSeq int
|
||||||
|
wheelPending bool
|
||||||
|
wheelFocus tuiFocus
|
||||||
|
wheelDelta int
|
||||||
wheelSeq int
|
wheelSeq int
|
||||||
detailView viewport.Model
|
detailView viewport.Model
|
||||||
searchInput textinput.Model
|
searchInput textinput.Model
|
||||||
@ -160,6 +171,22 @@ type tuiMenuItem struct {
|
|||||||
const tuiMenuSeparatorAction = "separator"
|
const tuiMenuSeparatorAction = "separator"
|
||||||
const tuiDoubleClickWindow = 450 * time.Millisecond
|
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 {
|
func (item tuiMenuItem) selectable() bool {
|
||||||
return item.action != "" && item.action != tuiMenuSeparatorAction
|
return item.action != "" && item.action != tuiMenuSeparatorAction
|
||||||
}
|
}
|
||||||
@ -177,6 +204,69 @@ func menuHasSection(items []tuiMenuItem, label string) bool {
|
|||||||
return false
|
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 {
|
type tuiNeighbor struct {
|
||||||
Thread store.Thread
|
Thread store.Thread
|
||||||
Score float64
|
Score float64
|
||||||
@ -255,6 +345,14 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.autoRefreshFromStore()
|
m.autoRefreshFromStore()
|
||||||
return m, m.autoRefreshCmd()
|
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:
|
case tuiWheelSettledMsg:
|
||||||
if msg.seq != m.wheelSeq {
|
if msg.seq != m.wheelSeq {
|
||||||
return m, nil
|
return m, nil
|
||||||
@ -292,6 +390,7 @@ func (m clusterBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.syncComponents()
|
m.syncComponents()
|
||||||
m.keepVisible()
|
m.keepVisible()
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
m.cancelQueuedWheelScroll()
|
||||||
if m.menuOpen {
|
if m.menuOpen {
|
||||||
return m.updateMenu(msg)
|
return m.updateMenu(msg)
|
||||||
}
|
}
|
||||||
@ -784,7 +883,12 @@ func (m clusterBrowserModel) helpLines(width int) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m clusterBrowserModel) menuLines(width int) []string {
|
func (m clusterBrowserModel) menuLines(width int) []string {
|
||||||
lines := []string{bold(firstNonEmpty(m.menuTitle, "Actions")), ""}
|
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()
|
visible := m.menuVisibleCount()
|
||||||
start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible))
|
start := clampInt(m.menuOff, 0, maxInt(0, len(m.menuItems)-visible))
|
||||||
end := minInt(len(m.menuItems), start+visible)
|
end := minInt(len(m.menuItems), start+visible)
|
||||||
@ -806,7 +910,7 @@ func (m clusterBrowserModel) menuLines(width int) []string {
|
|||||||
}
|
}
|
||||||
line := truncateCells(prefix+key+item.label, width)
|
line := truncateCells(prefix+key+item.label, width)
|
||||||
if index == m.menuIndex {
|
if index == m.menuIndex {
|
||||||
line = selectedMenuLineStyle(width).Render(padCells(line, width))
|
line = selectedMenuLineStyle(width, palette).Render(padCells(line, width))
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
@ -834,7 +938,7 @@ func (m clusterBrowserModel) renderFloatingMenu(view string) string {
|
|||||||
if len(lines) > maxInt(0, rect.h-2) {
|
if len(lines) > maxInt(0, rect.h-2) {
|
||||||
lines = lines[:maxInt(0, rect.h-2)]
|
lines = lines[:maxInt(0, rect.h-2)]
|
||||||
}
|
}
|
||||||
box := floatingMenuStyle(rect.w, rect.h).Render(strings.Join(lines, "\n"))
|
box := floatingMenuStyle(rect.w, rect.h, actionMenuColors(m.menuContext)).Render(strings.Join(lines, "\n"))
|
||||||
return overlayBlock(view, box, rect.x, rect.y, m.width)
|
return overlayBlock(view, box, rect.x, rect.y, m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -859,11 +963,11 @@ func (m clusterBrowserModel) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = "Help"
|
m.status = "Help"
|
||||||
case "b", "left", "backspace":
|
case "b", "left", "backspace":
|
||||||
if m.inMenuSubmenu() {
|
if m.inMenuSubmenu() {
|
||||||
m.openActionMenu()
|
m.openActionMenuFor(m.menuContext)
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
if m.inMenuSubmenu() {
|
if m.inMenuSubmenu() {
|
||||||
m.openActionMenu()
|
m.openActionMenuFor(m.menuContext)
|
||||||
}
|
}
|
||||||
case "/":
|
case "/":
|
||||||
cmd := m.startFilterInput()
|
cmd := m.startFilterInput()
|
||||||
@ -1050,6 +1154,9 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd {
|
|||||||
if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) {
|
if msg.Button != tea.MouseButtonLeft && msg.Button != tea.MouseButtonRight && !isMouseWheel(msg.Button) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !isMouseWheel(msg.Button) {
|
||||||
|
m.cancelQueuedWheelScroll()
|
||||||
|
}
|
||||||
if m.menuOpen {
|
if m.menuOpen {
|
||||||
m.handleMenuMouse(layout, msg)
|
m.handleMenuMouse(layout, msg)
|
||||||
return nil
|
return nil
|
||||||
@ -1115,8 +1222,14 @@ func (m *clusterBrowserModel) handleMouse(msg tea.MouseMsg) tea.Cmd {
|
|||||||
if msg.Action != tea.MouseActionPress {
|
if msg.Action != tea.MouseActionPress {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
context := m.actionMenuContextAt(layout, msg.X, msg.Y)
|
||||||
m.selectByMousePosition(layout, msg.X, msg.Y)
|
m.selectByMousePosition(layout, msg.X, msg.Y)
|
||||||
m.openActionMenu()
|
if context == focusMembers {
|
||||||
|
if _, ok := m.selectedMember(); !ok {
|
||||||
|
context = focusClusters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.openActionMenuFor(context)
|
||||||
m.placeFloatingMenu(layout, msg.X, msg.Y)
|
m.placeFloatingMenu(layout, msg.X, msg.Y)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -1251,10 +1364,70 @@ func (m *clusterBrowserModel) selectByMousePosition(layout tuiLayout, x, y int)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func (m *clusterBrowserModel) openActionMenu() {
|
||||||
m.menuItems = nil
|
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 {
|
if thread, ok := m.selectedThread(); ok {
|
||||||
m.menuItems = append(m.menuItems,
|
*items = append(*items,
|
||||||
tuiMenuSection("Thread"),
|
tuiMenuSection("Thread"),
|
||||||
tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"},
|
tuiMenuItem{label: fmt.Sprintf("Open #%d in browser", thread.Number), action: "open"},
|
||||||
tuiMenuItem{label: "Copy selected URL", action: "copy-url"},
|
tuiMenuItem{label: "Copy selected URL", action: "copy-url"},
|
||||||
@ -1264,54 +1437,68 @@ func (m *clusterBrowserModel) openActionMenu() {
|
|||||||
tuiMenuItem{label: "Load neighbors", action: "load-neighbors"},
|
tuiMenuItem{label: "Load neighbors", action: "load-neighbors"},
|
||||||
)
|
)
|
||||||
if thread.ClosedAtLocal != "" {
|
if thread.ClosedAtLocal != "" {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"})
|
*items = append(*items, tuiMenuItem{label: "Reopen locally...", action: "reopen-thread-confirm"})
|
||||||
} else {
|
} else {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"})
|
*items = append(*items, tuiMenuItem{label: "Close locally...", action: "close-thread-confirm"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m clusterBrowserModel) appendMemberClusterMenuItems(items *[]tuiMenuItem) {
|
||||||
if member, ok := m.selectedMember(); ok {
|
if member, ok := m.selectedMember(); ok {
|
||||||
sectionAdded := false
|
sectionAdded := false
|
||||||
if cluster, clusterOK := m.selectedCluster(); clusterOK {
|
if cluster, clusterOK := m.selectedCluster(); clusterOK {
|
||||||
if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" {
|
if clusterSupportsDurableLocalActions(cluster) && member.State == "excluded" {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"})
|
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) {
|
} else if clusterSupportsDurableLocalActions(cluster) {
|
||||||
m.menuItems = append(m.menuItems,
|
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("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"},
|
tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(member.BodySnippet) != "" {
|
if strings.TrimSpace(member.BodySnippet) != "" {
|
||||||
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") {
|
if !menuHasSection(*items, "Thread") {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuSection("Thread"))
|
*items = append(*items, tuiMenuSection("Thread"))
|
||||||
sectionAdded = true
|
sectionAdded = true
|
||||||
}
|
}
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"})
|
*items = append(*items, tuiMenuItem{label: "Copy body preview", action: "copy-body-preview"})
|
||||||
}
|
}
|
||||||
if len(member.Summaries) > 0 {
|
if len(member.Summaries) > 0 {
|
||||||
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") {
|
if !sectionAdded && !menuHasSection(*items, "Thread") {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuSection("Thread"))
|
*items = append(*items, tuiMenuSection("Thread"))
|
||||||
sectionAdded = true
|
sectionAdded = true
|
||||||
}
|
}
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"})
|
*items = append(*items, tuiMenuItem{label: "Copy summaries", action: "copy-summaries"})
|
||||||
}
|
}
|
||||||
if _, ok := m.neighborCache[member.Thread.ID]; ok {
|
if _, ok := m.neighborCache[member.Thread.ID]; ok {
|
||||||
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") {
|
if !sectionAdded && !menuHasSection(*items, "Thread") {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuSection("Thread"))
|
*items = append(*items, tuiMenuSection("Thread"))
|
||||||
}
|
}
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"})
|
*items = append(*items, tuiMenuItem{label: "Copy neighbors", action: "copy-neighbors"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m clusterBrowserModel) appendClusterMenuItems(items *[]tuiMenuItem, includeVisible bool) {
|
||||||
if m.hasSelectedCluster() {
|
if m.hasSelectedCluster() {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuSection("Cluster"))
|
*items = append(*items, tuiMenuSection("Cluster"))
|
||||||
if url, ok := m.selectedClusterURL(); ok {
|
if url, ok := m.selectedClusterURL(); ok {
|
||||||
cluster, _ := m.selectedCluster()
|
cluster, _ := m.selectedCluster()
|
||||||
m.menuItems = append(m.menuItems,
|
*items = append(*items,
|
||||||
tuiMenuItem{label: fmt.Sprintf("Open representative #%d", cluster.RepresentativeNumber), action: "open-cluster-representative", value: url},
|
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},
|
tuiMenuItem{label: "Copy representative URL", action: "copy-cluster-url", value: url},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
m.menuItems = append(m.menuItems,
|
*items = append(*items,
|
||||||
tuiMenuItem{label: "Copy cluster ID", action: "copy-cluster-id"},
|
tuiMenuItem{label: "Copy cluster ID", action: "copy-cluster-id"},
|
||||||
tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"},
|
tuiMenuItem{label: "Copy cluster name", action: "copy-cluster-name"},
|
||||||
tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"},
|
tuiMenuItem{label: "Copy cluster title", action: "copy-cluster-title"},
|
||||||
@ -1320,42 +1507,63 @@ func (m *clusterBrowserModel) openActionMenu() {
|
|||||||
cluster, _ := m.selectedCluster()
|
cluster, _ := m.selectedCluster()
|
||||||
if clusterSupportsDurableLocalActions(cluster) {
|
if clusterSupportsDurableLocalActions(cluster) {
|
||||||
if cluster.Status == "closed" || cluster.ClosedAt != "" {
|
if cluster.Status == "closed" || cluster.ClosedAt != "" {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"})
|
*items = append(*items, tuiMenuItem{label: "Reopen cluster locally...", action: "reopen-cluster-confirm"})
|
||||||
} else {
|
} else {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"})
|
*items = append(*items, tuiMenuItem{label: "Close cluster locally...", action: "close-cluster-confirm"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.hasDetail {
|
if m.hasDetail {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy member list", action: "copy-member-list"})
|
*items = append(*items, tuiMenuItem{label: "Copy member list", action: "copy-member-list"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(m.payload.Clusters) > 0 {
|
if includeVisible && len(m.payload.Clusters) > 0 {
|
||||||
if !menuHasSection(m.menuItems, "Cluster") {
|
if !menuHasSection(*items, "Cluster") {
|
||||||
m.menuItems = append(m.menuItems, tuiMenuSection("Cluster"))
|
*items = append(*items, tuiMenuSection("Cluster"))
|
||||||
}
|
}
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Copy visible clusters", action: "copy-visible-clusters"})
|
*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()
|
referenceLinks := m.referenceLinks()
|
||||||
if len(referenceLinks) > 0 {
|
if len(referenceLinks) > 0 {
|
||||||
m.menuItems = append(m.menuItems,
|
*items = append(*items,
|
||||||
tuiMenuSection("Links"),
|
tuiMenuSection("Links"),
|
||||||
tuiMenuItem{label: "Open first body link", action: "open-first-link"},
|
tuiMenuItem{label: "Open first body link", action: "open-first-link"},
|
||||||
tuiMenuItem{label: "Copy first body link", action: "copy-first-link"},
|
tuiMenuItem{label: "Copy first body link", action: "copy-first-link"},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(referenceLinks) > 1 {
|
if len(referenceLinks) > 1 {
|
||||||
m.menuItems = append(m.menuItems,
|
*items = append(*items,
|
||||||
tuiMenuItem{label: "Open body link...", action: "open-link-picker"},
|
tuiMenuItem{label: "Open body link...", action: "open-link-picker"},
|
||||||
tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"},
|
tuiMenuItem{label: "Copy body link...", action: "copy-link-picker"},
|
||||||
tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"},
|
tuiMenuItem{label: "Copy all body links", action: "copy-reference-links"},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m clusterBrowserModel) appendViewMenuItems(items *[]tuiMenuItem) {
|
||||||
viewItems := []tuiMenuItem{
|
viewItems := []tuiMenuItem{
|
||||||
tuiMenuSection("View"),
|
tuiMenuSection("View"),
|
||||||
tuiMenuItem{label: "Sort clusters by size", action: "sort-size"},
|
tuiMenuItem{label: "Sort clusters by size", action: "sort-size"},
|
||||||
tuiMenuItem{label: "Sort clusters by recent", action: "sort-recent"},
|
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 grouped", action: "member-sort-kind"},
|
||||||
tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"},
|
tuiMenuItem{label: "Member sort recent", action: "member-sort-recent"},
|
||||||
|
tuiMenuItem{label: "Member sort oldest", action: "member-sort-oldest"},
|
||||||
tuiMenuItem{label: "Filter clusters...", action: "filter"},
|
tuiMenuItem{label: "Filter clusters...", action: "filter"},
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.search) != "" {
|
if strings.TrimSpace(m.search) != "" {
|
||||||
@ -1374,17 +1582,7 @@ func (m *clusterBrowserModel) openActionMenu() {
|
|||||||
tuiMenuItem{label: "Help", action: "show-help"},
|
tuiMenuItem{label: "Help", action: "show-help"},
|
||||||
tuiMenuItem{label: "Quit", action: "quit"},
|
tuiMenuItem{label: "Quit", action: "quit"},
|
||||||
)
|
)
|
||||||
m.menuItems = append(m.menuItems, viewItems...)
|
*items = append(*items, viewItems...)
|
||||||
if len(m.menuItems) == 0 {
|
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "No actions available", action: "close-menu"})
|
|
||||||
}
|
|
||||||
m.menuItems = append(m.menuItems, tuiMenuItem{label: "Close menu", action: "close-menu"})
|
|
||||||
m.menuTitle = "Actions"
|
|
||||||
m.menuIndex = m.firstSelectableMenuIndex()
|
|
||||||
m.menuOff = 0
|
|
||||||
m.menuOpen = true
|
|
||||||
m.showHelp = false
|
|
||||||
m.status = "Action menu"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *clusterBrowserModel) clearMenuPlacement() {
|
func (m *clusterBrowserModel) clearMenuPlacement() {
|
||||||
@ -1504,6 +1702,12 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
|
|||||||
m.loadSelectedCluster()
|
m.loadSelectedCluster()
|
||||||
m.status = "Sort: recent"
|
m.status = "Sort: recent"
|
||||||
return true
|
return true
|
||||||
|
case "sort-oldest":
|
||||||
|
m.payload.Sort = "oldest"
|
||||||
|
m.sortClusters()
|
||||||
|
m.loadSelectedCluster()
|
||||||
|
m.status = "Sort: oldest"
|
||||||
|
return true
|
||||||
case "member-sort-kind":
|
case "member-sort-kind":
|
||||||
m.memberSort = memberSortKind
|
m.memberSort = memberSortKind
|
||||||
m.sortMembers()
|
m.sortMembers()
|
||||||
@ -1514,6 +1718,11 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
|
|||||||
m.sortMembers()
|
m.sortMembers()
|
||||||
m.status = "Member sort: recent"
|
m.status = "Member sort: recent"
|
||||||
return true
|
return true
|
||||||
|
case "member-sort-oldest":
|
||||||
|
m.memberSort = memberSortOldest
|
||||||
|
m.sortMembers()
|
||||||
|
m.status = "Member sort: oldest"
|
||||||
|
return true
|
||||||
case "refresh":
|
case "refresh":
|
||||||
m.refreshFromStore()
|
m.refreshFromStore()
|
||||||
return true
|
return true
|
||||||
@ -1695,7 +1904,7 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
case "back-to-actions":
|
case "back-to-actions":
|
||||||
m.openActionMenu()
|
m.openActionMenuFor(m.menuContext)
|
||||||
return false
|
return false
|
||||||
case "select-repo":
|
case "select-repo":
|
||||||
m.switchRepository(item.value)
|
m.switchRepository(item.value)
|
||||||
@ -2326,12 +2535,62 @@ func (m *clusterBrowserModel) mouseWheel(layout tuiLayout, msg tea.MouseMsg, del
|
|||||||
m.clearLastClick()
|
m.clearLastClick()
|
||||||
switch {
|
switch {
|
||||||
case layout.clusters.contains(msg.X, msg.Y):
|
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
|
m.focus = focusClusters
|
||||||
return m.moveClusterByWheel(delta)
|
return m.moveClusterByWheel(delta)
|
||||||
case layout.members.contains(msg.X, msg.Y):
|
case focusMembers:
|
||||||
m.focus = focusMembers
|
m.focus = focusMembers
|
||||||
m.move(delta)
|
m.move(delta)
|
||||||
case layout.detail.contains(msg.X, msg.Y):
|
case focusDetail:
|
||||||
m.focus = focusDetail
|
m.focus = focusDetail
|
||||||
m.move(delta)
|
m.move(delta)
|
||||||
default:
|
default:
|
||||||
@ -2510,7 +2769,10 @@ func clusterColumns(width int, sortMode string) []table.Column {
|
|||||||
cntTitle = "cnt*"
|
cntTitle = "cnt*"
|
||||||
}
|
}
|
||||||
if sortMode == "recent" {
|
if sortMode == "recent" {
|
||||||
ageTitle = "age*"
|
ageTitle = "age-"
|
||||||
|
}
|
||||||
|
if sortMode == "oldest" {
|
||||||
|
ageTitle = "age+"
|
||||||
}
|
}
|
||||||
return []table.Column{
|
return []table.Column{
|
||||||
{Title: "id", Width: idW},
|
{Title: "id", Width: idW},
|
||||||
@ -2541,7 +2803,10 @@ func memberColumns(width int, sortMode tuiMemberSort) []table.Column {
|
|||||||
stateTitle = "st*"
|
stateTitle = "st*"
|
||||||
}
|
}
|
||||||
if sortMode == memberSortRecent {
|
if sortMode == memberSortRecent {
|
||||||
ageTitle = "age*"
|
ageTitle = "age-"
|
||||||
|
}
|
||||||
|
if sortMode == memberSortOldest {
|
||||||
|
ageTitle = "age+"
|
||||||
}
|
}
|
||||||
if sortMode == memberSortTitle {
|
if sortMode == memberSortTitle {
|
||||||
titleTitle = "title*"
|
titleTitle = "title*"
|
||||||
@ -2614,6 +2879,14 @@ func (m *clusterBrowserModel) sortClusters() {
|
|||||||
return 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))
|
return parseTime(left.UpdatedAt).After(parseTime(right.UpdatedAt))
|
||||||
})
|
})
|
||||||
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1))
|
m.selected = clampInt(m.selected, 0, maxInt(0, len(m.payload.Clusters)-1))
|
||||||
@ -2624,7 +2897,11 @@ func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) {
|
|||||||
if relativeX < columnRightEdge(columns, 1) {
|
if relativeX < columnRightEdge(columns, 1) {
|
||||||
m.payload.Sort = "size"
|
m.payload.Sort = "size"
|
||||||
} else if relativeX >= columnLeftEdge(columns, len(columns)-1) {
|
} else if relativeX >= columnLeftEdge(columns, len(columns)-1) {
|
||||||
m.payload.Sort = "recent"
|
if m.payload.Sort == "recent" {
|
||||||
|
m.payload.Sort = "oldest"
|
||||||
|
} else {
|
||||||
|
m.payload.Sort = "recent"
|
||||||
|
}
|
||||||
} else if m.payload.Sort == "recent" {
|
} else if m.payload.Sort == "recent" {
|
||||||
m.payload.Sort = "size"
|
m.payload.Sort = "size"
|
||||||
} else {
|
} else {
|
||||||
@ -2834,8 +3111,15 @@ func (m clusterBrowserModel) currentClusterID() int64 {
|
|||||||
return m.payload.Clusters[m.selected].ID
|
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) {
|
func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSummary, error) {
|
||||||
viewLimit := maxInt(20, m.payload.Limit)
|
viewLimit := m.clusterRefreshLimit()
|
||||||
clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
clusters, err := m.store.ListDisplayClusterSummaries(m.ctx, store.ClusterSummaryOptions{
|
||||||
RepoID: m.repoID,
|
RepoID: m.repoID,
|
||||||
IncludeClosed: m.showClosed,
|
IncludeClosed: m.showClosed,
|
||||||
@ -2850,7 +3134,7 @@ func (m *clusterBrowserModel) loadClusterSummariesFromStore() ([]store.ClusterSu
|
|||||||
RepoID: m.repoID,
|
RepoID: m.repoID,
|
||||||
IncludeClosed: m.showClosed,
|
IncludeClosed: m.showClosed,
|
||||||
MinSize: 1,
|
MinSize: 1,
|
||||||
Limit: maxInt(defaultTUIWorkingSetLimit, maxInt(m.payload.Limit, len(m.allClusters))),
|
Limit: viewLimit,
|
||||||
Sort: m.payload.Sort,
|
Sort: m.payload.Sort,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -3003,7 +3287,11 @@ func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) {
|
|||||||
case relativeX < columnRightEdge(columns, 1):
|
case relativeX < columnRightEdge(columns, 1):
|
||||||
m.memberSort = memberSortState
|
m.memberSort = memberSortState
|
||||||
case relativeX < columnRightEdge(columns, 2):
|
case relativeX < columnRightEdge(columns, 2):
|
||||||
m.memberSort = memberSortRecent
|
if m.memberSort == memberSortRecent {
|
||||||
|
m.memberSort = memberSortOldest
|
||||||
|
} else {
|
||||||
|
m.memberSort = memberSortRecent
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if m.memberSort == memberSortTitle {
|
if m.memberSort == memberSortTitle {
|
||||||
m.memberSort = memberSortKind
|
m.memberSort = memberSortKind
|
||||||
@ -3071,6 +3359,8 @@ func (m *clusterBrowserModel) sortMembers() {
|
|||||||
switch m.memberSort {
|
switch m.memberSort {
|
||||||
case memberSortRecent:
|
case memberSortRecent:
|
||||||
return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub))
|
return parseTime(left.UpdatedAtGitHub).After(parseTime(right.UpdatedAtGitHub))
|
||||||
|
case memberSortOldest:
|
||||||
|
return parseTime(left.UpdatedAtGitHub).Before(parseTime(right.UpdatedAtGitHub))
|
||||||
case memberSortNumber:
|
case memberSortNumber:
|
||||||
return left.Number < right.Number
|
return left.Number < right.Number
|
||||||
case memberSortState:
|
case memberSortState:
|
||||||
@ -3543,7 +3833,7 @@ func nextFocus(current tuiFocus, delta int) tuiFocus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nextMemberSort(current tuiMemberSort) tuiMemberSort {
|
func nextMemberSort(current tuiMemberSort) tuiMemberSort {
|
||||||
order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortNumber, memberSortState, memberSortTitle}
|
order := []tuiMemberSort{memberSortKind, memberSortRecent, memberSortOldest, memberSortNumber, memberSortState, memberSortTitle}
|
||||||
for index, item := range order {
|
for index, item := range order {
|
||||||
if item == current {
|
if item == current {
|
||||||
return order[(index+1)%len(order)]
|
return order[(index+1)%len(order)]
|
||||||
@ -3784,43 +4074,43 @@ func clusterRowStyle(cluster store.ClusterSummary, selected bool, focused bool)
|
|||||||
switch status {
|
switch status {
|
||||||
case "closed":
|
case "closed":
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a")
|
return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG)
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936"))
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG))
|
||||||
case "merged", "split":
|
case "merged", "split":
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle(focused, "#ead7ff", "#1b0e2a", "#342042", "#dfbdff")
|
return selectedRowStyle(focused, "#394052", "#d8c4ff", "#242936", "#b8a3d8")
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#d8c4ff")).Background(lipgloss.Color("#21172d"))
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#b8a3d8")).Background(lipgloss.Color("#151620"))
|
||||||
default:
|
default:
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae")
|
return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG)
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115"))
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style {
|
func memberRowStyle(row memberRow, selected bool, focused bool) lipgloss.Style {
|
||||||
if !row.selectable {
|
if !row.selectable {
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#9bc53d")).Bold(true)
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiMutedAccent)).Bold(true)
|
||||||
}
|
}
|
||||||
state := strings.ToLower(memberDisplayState(row.member))
|
state := strings.ToLower(memberDisplayState(row.member))
|
||||||
switch state {
|
switch state {
|
||||||
case "closed", "local", "merged":
|
case "closed", "local", "merged":
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle(focused, "#ffe0ad", "#1d1304", "#473111", "#ffd08a")
|
return selectedRowStyle(focused, tuiClosedSelectedBG, tuiClosedSelectedFG, tuiClosedSelectedBlurBG, tuiClosedSelectedBlurFG)
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#aab2bf")).Background(lipgloss.Color("#242936"))
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiClosedRowFG)).Background(lipgloss.Color(tuiClosedRowBG))
|
||||||
default:
|
default:
|
||||||
if selected {
|
if selected {
|
||||||
return selectedRowStyle(focused, "#d7ffd2", "#061607", "#14351d", "#a8f0ae")
|
return selectedRowStyle(focused, tuiOpenSelectedBG, tuiOpenSelectedFG, tuiOpenSelectedBlurBG, tuiOpenSelectedBlurFG)
|
||||||
}
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#e8ffe8")).Background(lipgloss.Color("#0f2115"))
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(tuiOpenRowFG)).Background(lipgloss.Color(tuiOpenRowBG))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style {
|
func selectedRowStyle(focused bool, focusedBG, focusedFG, blurredBG, blurredFG string) lipgloss.Style {
|
||||||
style := lipgloss.NewStyle().Bold(true)
|
style := lipgloss.NewStyle()
|
||||||
if focused {
|
if focused {
|
||||||
return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG))
|
return style.Foreground(lipgloss.Color(focusedFG)).Background(lipgloss.Color(focusedBG))
|
||||||
}
|
}
|
||||||
@ -4162,21 +4452,21 @@ func selectedFG(focused bool) string {
|
|||||||
return "#f7f7ff"
|
return "#f7f7ff"
|
||||||
}
|
}
|
||||||
|
|
||||||
func floatingMenuStyle(width, height int) lipgloss.Style {
|
func floatingMenuStyle(width, height int, palette actionMenuPalette) lipgloss.Style {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(maxInt(1, width-2)).
|
Width(maxInt(1, width-2)).
|
||||||
Height(maxInt(1, height-2)).
|
Height(maxInt(1, height-2)).
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("#ffd166")).
|
BorderForeground(lipgloss.Color(palette.accent)).
|
||||||
Background(lipgloss.Color("#151922")).
|
Background(lipgloss.Color(palette.background)).
|
||||||
Foreground(lipgloss.Color("#f7f7ff"))
|
Foreground(lipgloss.Color(palette.foreground))
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectedMenuLineStyle(width int) lipgloss.Style {
|
func selectedMenuLineStyle(width int, palette actionMenuPalette) lipgloss.Style {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(maxInt(1, width)).
|
Width(maxInt(1, width)).
|
||||||
Background(lipgloss.Color("#ffd166")).
|
Background(lipgloss.Color(palette.selectedBG)).
|
||||||
Foreground(lipgloss.Color("#05070d")).
|
Foreground(lipgloss.Color(palette.selectedFG)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -424,7 +424,9 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) {
|
|||||||
model.width = 190
|
model.width = 190
|
||||||
model.height = 34
|
model.height = 34
|
||||||
layout := model.layout()
|
layout := model.layout()
|
||||||
|
initialSelected := model.selected
|
||||||
|
|
||||||
|
queued := 0
|
||||||
for i := 0; i < 80; i++ {
|
for i := 0; i < 80; i++ {
|
||||||
cmd := model.handleMouse(tea.MouseMsg{
|
cmd := model.handleMouse(tea.MouseMsg{
|
||||||
X: layout.clusters.x + 2,
|
X: layout.clusters.x + 2,
|
||||||
@ -432,12 +434,30 @@ func TestTUIFastWheelScrollKeepsFrameStable(t *testing.T) {
|
|||||||
Action: tea.MouseActionPress,
|
Action: tea.MouseActionPress,
|
||||||
Button: tea.MouseButtonWheelDown,
|
Button: tea.MouseButtonWheelDown,
|
||||||
})
|
})
|
||||||
if cmd == nil && model.selected < len(model.payload.Clusters)-1 {
|
if cmd != nil {
|
||||||
t.Fatal("cluster wheel movement should defer detail reload until scrolling settles")
|
queued++
|
||||||
}
|
}
|
||||||
model.keepVisible()
|
model.keepVisible()
|
||||||
model.syncComponents()
|
model.syncComponents()
|
||||||
}
|
}
|
||||||
|
if queued != 1 {
|
||||||
|
t.Fatalf("wheel burst queued %d frame ticks, want 1", queued)
|
||||||
|
}
|
||||||
|
if model.selected != initialSelected {
|
||||||
|
t.Fatalf("wheel burst moved immediately to %d, want %d", model.selected, initialSelected)
|
||||||
|
}
|
||||||
|
if model.wheelDelta != tuiWheelMaxBufferedDelta {
|
||||||
|
t.Fatalf("wheel burst delta = %d, want capped %d", model.wheelDelta, tuiWheelMaxBufferedDelta)
|
||||||
|
}
|
||||||
|
updated, cmd := model.Update(tuiWheelScrollMsg{seq: model.wheelScrollSeq})
|
||||||
|
model = updated.(clusterBrowserModel)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("cluster wheel frame should defer detail reload until scrolling settles")
|
||||||
|
}
|
||||||
|
wantSelected := clampInt(initialSelected+tuiWheelMaxBufferedDelta, 0, len(model.payload.Clusters)-1)
|
||||||
|
if model.selected != wantSelected {
|
||||||
|
t.Fatalf("wheel burst selected = %d, want capped movement to %d", model.selected, wantSelected)
|
||||||
|
}
|
||||||
|
|
||||||
view := model.View()
|
view := model.View()
|
||||||
lines := strings.Split(view, "\n")
|
lines := strings.Split(view, "\n")
|
||||||
@ -526,6 +546,70 @@ func TestTUIMouseHeaderSortsClusterRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTUIClusterAgeHeaderTogglesDirection(t *testing.T) {
|
||||||
|
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
||||||
|
Repository: "openclaw/openclaw",
|
||||||
|
Sort: "recent",
|
||||||
|
Clusters: sampleTUIClusters(),
|
||||||
|
})
|
||||||
|
model.width = 140
|
||||||
|
model.height = 32
|
||||||
|
columns := clusterColumns(maxInt(24, model.layout().clusters.w-4), model.payload.Sort)
|
||||||
|
ageX := columnLeftEdge(columns, len(columns)-1)
|
||||||
|
|
||||||
|
model.sortClustersFromHeader(ageX)
|
||||||
|
if model.payload.Sort != "oldest" {
|
||||||
|
t.Fatalf("age header sort = %q, want oldest", model.payload.Sort)
|
||||||
|
}
|
||||||
|
if model.payload.Clusters[0].ID != 1 {
|
||||||
|
t.Fatalf("oldest sort first cluster id = %d, want 1", model.payload.Clusters[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = clusterColumns(maxInt(24, model.layout().clusters.w-4), model.payload.Sort)
|
||||||
|
model.sortClustersFromHeader(columnLeftEdge(columns, len(columns)-1))
|
||||||
|
if model.payload.Sort != "recent" {
|
||||||
|
t.Fatalf("age header second sort = %q, want recent", model.payload.Sort)
|
||||||
|
}
|
||||||
|
if model.payload.Clusters[0].ID != 2 {
|
||||||
|
t.Fatalf("recent sort first cluster id = %d, want 2", model.payload.Clusters[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTUIMemberAgeHeaderTogglesDirection(t *testing.T) {
|
||||||
|
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
||||||
|
Repository: "openclaw/openclaw",
|
||||||
|
Sort: "recent",
|
||||||
|
Clusters: sampleTUIClusters(),
|
||||||
|
})
|
||||||
|
model.width = 140
|
||||||
|
model.height = 32
|
||||||
|
model.detail = store.ClusterDetail{Cluster: sampleTUIClusters()[0], Members: []store.ClusterMemberDetail{
|
||||||
|
{Thread: store.Thread{ID: 1, Number: 10, Kind: "issue", State: "open", Title: "Older", UpdatedAtGitHub: "2026-04-27T10:00:00Z"}},
|
||||||
|
{Thread: store.Thread{ID: 2, Number: 11, Kind: "issue", State: "open", Title: "Newer", UpdatedAtGitHub: "2026-04-27T11:00:00Z"}},
|
||||||
|
}}
|
||||||
|
model.hasDetail = true
|
||||||
|
model.sortMembers()
|
||||||
|
columns := memberColumns(maxInt(24, model.layout().members.w-4), model.memberSort)
|
||||||
|
ageX := columnLeftEdge(columns, 2)
|
||||||
|
|
||||||
|
model.sortMembersFromHeader(ageX)
|
||||||
|
if model.memberSort != memberSortRecent {
|
||||||
|
t.Fatalf("member age header sort = %q, want recent", model.memberSort)
|
||||||
|
}
|
||||||
|
if model.memberRows[0].member.Thread.ID != 2 {
|
||||||
|
t.Fatalf("recent member first id = %d, want 2", model.memberRows[0].member.Thread.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
columns = memberColumns(maxInt(24, model.layout().members.w-4), model.memberSort)
|
||||||
|
model.sortMembersFromHeader(columnLeftEdge(columns, 2))
|
||||||
|
if model.memberSort != memberSortOldest {
|
||||||
|
t.Fatalf("member age header second sort = %q, want oldest", model.memberSort)
|
||||||
|
}
|
||||||
|
if model.memberRows[0].member.Thread.ID != 1 {
|
||||||
|
t.Fatalf("oldest member first id = %d, want 1", model.memberRows[0].member.Thread.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTUIClusterRowsShowClusterIDs(t *testing.T) {
|
func TestTUIClusterRowsShowClusterIDs(t *testing.T) {
|
||||||
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
||||||
Repository: "openclaw/openclaw",
|
Repository: "openclaw/openclaw",
|
||||||
@ -621,6 +705,19 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) {
|
|||||||
if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) {
|
if fmt.Sprint(openCluster.GetBackground()) == fmt.Sprint(closedCluster.GetBackground()) {
|
||||||
t.Fatalf("open and closed cluster backgrounds should differ")
|
t.Fatalf("open and closed cluster backgrounds should differ")
|
||||||
}
|
}
|
||||||
|
if fmt.Sprint(openCluster.GetForeground()) != tuiOpenRowFG {
|
||||||
|
t.Fatalf("open cluster foreground = %v, want %s", openCluster.GetForeground(), tuiOpenRowFG)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(openCluster.GetBackground()) != tuiOpenRowBG {
|
||||||
|
t.Fatalf("open cluster background = %v, want %s", openCluster.GetBackground(), tuiOpenRowBG)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(closedCluster.GetForeground()) != tuiClosedRowFG {
|
||||||
|
t.Fatalf("closed cluster foreground = %v, want %s", closedCluster.GetForeground(), tuiClosedRowFG)
|
||||||
|
}
|
||||||
|
selectedCluster := clusterRowStyle(store.ClusterSummary{Status: "active"}, true, true)
|
||||||
|
if fmt.Sprint(selectedCluster.GetBackground()) != tuiOpenSelectedBG {
|
||||||
|
t.Fatalf("selected cluster background = %v, want %s", selectedCluster.GetBackground(), tuiOpenSelectedBG)
|
||||||
|
}
|
||||||
clusterView := renderStyledTable([]table.Column{{Title: "id", Width: 8}, {Title: "state", Width: 8}}, []table.Row{{"C1", "OPEN"}, {"C2", "CLOSED"}}, 0, 2, 20, "#5bc0eb", func(index int) lipgloss.Style {
|
clusterView := renderStyledTable([]table.Column{{Title: "id", Width: 8}, {Title: "state", Width: 8}}, []table.Row{{"C1", "OPEN"}, {"C2", "CLOSED"}}, 0, 2, 20, "#5bc0eb", func(index int) lipgloss.Style {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
return openCluster
|
return openCluster
|
||||||
@ -647,6 +744,12 @@ func TestTUIRenderedRowsStyleOpenAndClosedStates(t *testing.T) {
|
|||||||
if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) {
|
if fmt.Sprint(openMember.GetBackground()) == fmt.Sprint(closedMember.GetBackground()) {
|
||||||
t.Fatalf("open and closed member backgrounds should differ")
|
t.Fatalf("open and closed member backgrounds should differ")
|
||||||
}
|
}
|
||||||
|
if fmt.Sprint(openMember.GetForeground()) != tuiOpenRowFG {
|
||||||
|
t.Fatalf("open member foreground = %v, want %s", openMember.GetForeground(), tuiOpenRowFG)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(closedMember.GetForeground()) != tuiClosedRowFG {
|
||||||
|
t.Fatalf("closed member foreground = %v, want %s", closedMember.GetForeground(), tuiClosedRowFG)
|
||||||
|
}
|
||||||
memberView := renderStyledTable([]table.Column{{Title: "number", Width: 8}, {Title: "st", Width: 8}}, []table.Row{{"#1", "opn"}, {"#2", "cls"}}, 0, 2, 20, "#9bc53d", func(index int) lipgloss.Style {
|
memberView := renderStyledTable([]table.Column{{Title: "number", Width: 8}, {Title: "st", Width: 8}}, []table.Row{{"#1", "opn"}, {"#2", "cls"}}, 0, 2, 20, "#9bc53d", func(index int) lipgloss.Style {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
return openMember
|
return openMember
|
||||||
@ -909,14 +1012,13 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) {
|
|||||||
if !model.menuFloating {
|
if !model.menuFloating {
|
||||||
t.Fatal("expected right click action menu to float")
|
t.Fatal("expected right click action menu to float")
|
||||||
}
|
}
|
||||||
|
if model.menuTitle != "Cluster Actions" || model.menuContext != focusClusters {
|
||||||
|
t.Fatalf("cluster context menu title/context = %q/%q", model.menuTitle, model.menuContext)
|
||||||
|
}
|
||||||
if model.selected != 1 {
|
if model.selected != 1 {
|
||||||
t.Fatalf("right click selected %d, want 1", model.selected)
|
t.Fatalf("right click selected %d, want 1", model.selected)
|
||||||
}
|
}
|
||||||
labels := make([]string, 0, len(model.menuItems))
|
joinedLabels := strings.Join(menuLabels(model.menuItems), "\n")
|
||||||
for _, item := range model.menuItems {
|
|
||||||
labels = append(labels, item.label)
|
|
||||||
}
|
|
||||||
joinedLabels := strings.Join(labels, "\n")
|
|
||||||
for _, want := range []string{"Copy cluster ID", "Copy cluster name", "Copy cluster title", "Copy cluster summary"} {
|
for _, want := range []string{"Copy cluster ID", "Copy cluster name", "Copy cluster title", "Copy cluster summary"} {
|
||||||
if !strings.Contains(joinedLabels, want) {
|
if !strings.Contains(joinedLabels, want) {
|
||||||
t.Fatalf("expected cluster action %q, got %+v", want, model.menuItems)
|
t.Fatalf("expected cluster action %q, got %+v", want, model.menuItems)
|
||||||
@ -925,6 +1027,57 @@ func TestTUIRightClickOpensActionMenu(t *testing.T) {
|
|||||||
if !strings.Contains(joinedLabels, "Copy visible clusters") {
|
if !strings.Contains(joinedLabels, "Copy visible clusters") {
|
||||||
t.Fatalf("expected visible cluster action menu item, got %+v", model.menuItems)
|
t.Fatalf("expected visible cluster action menu item, got %+v", model.menuItems)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(joinedLabels, "Copy selected URL") {
|
||||||
|
t.Fatalf("cluster menu should not include selected member actions:\n%s", joinedLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTUIRightClickMemberRowOpensMemberActions(t *testing.T) {
|
||||||
|
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
||||||
|
Repository: "openclaw/openclaw",
|
||||||
|
Sort: "recent",
|
||||||
|
Clusters: sampleTUIClusters(),
|
||||||
|
})
|
||||||
|
model.width = 140
|
||||||
|
model.height = 32
|
||||||
|
model.memberRows = []memberRow{
|
||||||
|
{label: "ISSUES (1)"},
|
||||||
|
{
|
||||||
|
selectable: true,
|
||||||
|
member: store.ClusterMemberDetail{Thread: store.Thread{
|
||||||
|
Number: 42,
|
||||||
|
Kind: "issue",
|
||||||
|
State: "open",
|
||||||
|
Title: "Selected issue",
|
||||||
|
HTMLURL: "https://github.com/openclaw/openclaw/issues/42",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
layout := model.layout()
|
||||||
|
|
||||||
|
model.handleMouse(tea.MouseMsg{
|
||||||
|
X: layout.members.x + 2,
|
||||||
|
Y: layout.members.y + 4,
|
||||||
|
Action: tea.MouseActionPress,
|
||||||
|
Button: tea.MouseButtonRight,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !model.menuOpen || !model.menuFloating {
|
||||||
|
t.Fatalf("expected floating member action menu, open=%v floating=%v", model.menuOpen, model.menuFloating)
|
||||||
|
}
|
||||||
|
if model.menuTitle != "Member Actions" || model.menuContext != focusMembers {
|
||||||
|
t.Fatalf("member context menu title/context = %q/%q", model.menuTitle, model.menuContext)
|
||||||
|
}
|
||||||
|
joinedLabels := strings.Join(menuLabels(model.menuItems), "\n")
|
||||||
|
if !strings.Contains(joinedLabels, "Open #42 in browser") {
|
||||||
|
t.Fatalf("member menu should include selected thread action:\n%s", joinedLabels)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joinedLabels, "Copy cluster summary") {
|
||||||
|
t.Fatalf("member menu should keep cluster context actions:\n%s", joinedLabels)
|
||||||
|
}
|
||||||
|
if strings.Contains(joinedLabels, "Copy visible clusters") {
|
||||||
|
t.Fatalf("member menu should not include cluster-table bulk actions:\n%s", joinedLabels)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) {
|
func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) {
|
||||||
@ -961,11 +1114,10 @@ func TestTUIRightClickMemberHeaderOpensClusterActions(t *testing.T) {
|
|||||||
if !model.menuOpen {
|
if !model.menuOpen {
|
||||||
t.Fatal("expected right click to open action menu")
|
t.Fatal("expected right click to open action menu")
|
||||||
}
|
}
|
||||||
labels := make([]string, 0, len(model.menuItems))
|
if model.menuTitle != "Cluster Actions" || model.menuContext != focusClusters {
|
||||||
for _, item := range model.menuItems {
|
t.Fatalf("member header context menu title/context = %q/%q", model.menuTitle, model.menuContext)
|
||||||
labels = append(labels, item.label)
|
|
||||||
}
|
}
|
||||||
joinedLabels := strings.Join(labels, "\n")
|
joinedLabels := strings.Join(menuLabels(model.menuItems), "\n")
|
||||||
if strings.Contains(joinedLabels, "Copy selected URL") {
|
if strings.Contains(joinedLabels, "Copy selected URL") {
|
||||||
t.Fatalf("member header menu should not use stale selected thread:\n%s", joinedLabels)
|
t.Fatalf("member header menu should not use stale selected thread:\n%s", joinedLabels)
|
||||||
}
|
}
|
||||||
@ -3510,6 +3662,54 @@ func TestTUIAutoRefreshIsQuietUntilClustersChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTUIAutoRefreshPreservesUnboundedViewport(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 35; i++ {
|
||||||
|
clusterID := int64(100 + i)
|
||||||
|
if err := seedTUICluster(ctx, st, repoID, clusterID, 1000+i, fmt.Sprintf("cluster %02d", i)); err != nil {
|
||||||
|
t.Fatalf("seed cluster %d: %v", clusterID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clusters, err := st.ListDisplayClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 0, Sort: "recent"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list clusters: %v", err)
|
||||||
|
}
|
||||||
|
if len(clusters) <= 20 {
|
||||||
|
t.Fatalf("seeded viewport has %d clusters, want more than refresh floor", len(clusters))
|
||||||
|
}
|
||||||
|
|
||||||
|
model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{
|
||||||
|
Repository: "openclaw/openclaw",
|
||||||
|
Sort: "recent",
|
||||||
|
Clusters: clusters,
|
||||||
|
})
|
||||||
|
if err := seedTUICluster(ctx, st, repoID, 200, 2000, "new refresh cluster"); err != nil {
|
||||||
|
t.Fatalf("seed new cluster: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.autoRefreshFromStore()
|
||||||
|
|
||||||
|
if len(model.payload.Clusters) <= 20 {
|
||||||
|
t.Fatalf("auto refresh collapsed viewport to %d clusters", len(model.payload.Clusters))
|
||||||
|
}
|
||||||
|
if len(model.payload.Clusters) != 36 {
|
||||||
|
t.Fatalf("auto refresh clusters = %d, want 36", len(model.payload.Clusters))
|
||||||
|
}
|
||||||
|
if model.status != "Auto refreshed 36 cluster(s)" {
|
||||||
|
t.Fatalf("auto refresh status = %q", model.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTUIEmptyStateSuggestsRecoveryActions(t *testing.T) {
|
func TestTUIEmptyStateSuggestsRecoveryActions(t *testing.T) {
|
||||||
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
|
||||||
Repository: "openclaw/openclaw",
|
Repository: "openclaw/openclaw",
|
||||||
@ -3753,6 +3953,14 @@ func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int6
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func menuLabels(items []tuiMenuItem) []string {
|
||||||
|
labels := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
labels = append(labels, item.label)
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) {
|
func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) {
|
||||||
firstID, err := st.UpsertThread(ctx, store.Thread{
|
firstID, err := st.UpsertThread(ctx, store.Thread{
|
||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
|
|||||||
@ -133,6 +133,8 @@ func (s *Store) ListRunClusterSummaries(ctx context.Context, options ClusterSumm
|
|||||||
orderBy := `latest_updated_at desc, c.id desc`
|
orderBy := `latest_updated_at desc, c.id desc`
|
||||||
if options.Sort == "size" {
|
if options.Sort == "size" {
|
||||||
orderBy = `c.member_count desc, c.id asc`
|
orderBy = `c.member_count desc, c.id asc`
|
||||||
|
} else if options.Sort == "oldest" {
|
||||||
|
orderBy = `latest_updated_at asc, c.id asc`
|
||||||
}
|
}
|
||||||
limit := options.Limit
|
limit := options.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@ -212,6 +214,8 @@ func (s *Store) listDurableClusterSummaries(ctx context.Context, options Cluster
|
|||||||
orderBy := `coalesce(cg.updated_at, '') desc, cg.id desc`
|
orderBy := `coalesce(cg.updated_at, '') desc, cg.id desc`
|
||||||
if options.Sort == "size" {
|
if options.Sort == "size" {
|
||||||
orderBy = `member_count desc, cg.id asc`
|
orderBy = `member_count desc, cg.id asc`
|
||||||
|
} else if options.Sort == "oldest" {
|
||||||
|
orderBy = `coalesce(cg.updated_at, '') asc, cg.id asc`
|
||||||
}
|
}
|
||||||
limit := options.Limit
|
limit := options.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
@ -377,6 +381,15 @@ func sortClusterSummaries(clusters []ClusterSummary, sortMode string) {
|
|||||||
}
|
}
|
||||||
return left.ID < right.ID
|
return left.ID < right.ID
|
||||||
}
|
}
|
||||||
|
if sortMode == "oldest" {
|
||||||
|
if left.UpdatedAt != right.UpdatedAt {
|
||||||
|
return left.UpdatedAt < right.UpdatedAt
|
||||||
|
}
|
||||||
|
if left.MemberCount != right.MemberCount {
|
||||||
|
return left.MemberCount > right.MemberCount
|
||||||
|
}
|
||||||
|
return left.ID < right.ID
|
||||||
|
}
|
||||||
if left.UpdatedAt != right.UpdatedAt {
|
if left.UpdatedAt != right.UpdatedAt {
|
||||||
return left.UpdatedAt > right.UpdatedAt
|
return left.UpdatedAt > right.UpdatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,19 @@ func TestListClusterSummaries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSortClusterSummariesOldest(t *testing.T) {
|
||||||
|
clusters := []ClusterSummary{
|
||||||
|
{ID: 2, MemberCount: 1, UpdatedAt: "2026-04-27T11:00:00Z"},
|
||||||
|
{ID: 1, MemberCount: 5, UpdatedAt: "2026-04-27T10:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
sortClusterSummaries(clusters, "oldest")
|
||||||
|
|
||||||
|
if clusters[0].ID != 1 || clusters[1].ID != 2 {
|
||||||
|
t.Fatalf("oldest sort order = %d,%d; want 1,2", clusters[0].ID, clusters[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDurableClusterSummariesUsePrimaryOpenMembers(t *testing.T) {
|
func TestDurableClusterSummariesUsePrimaryOpenMembers(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user