fix(tui): toggle age sort direction

This commit is contained in:
Vincent Koc 2026-05-01 01:56:01 -07:00
parent ce8e8ed436
commit d722b934e6
No known key found for this signature in database
5 changed files with 138 additions and 10 deletions

View File

@ -976,7 +976,7 @@ func (a *App) runClusterList(ctx context.Context, command string, args []string,
fs.SetOutput(io.Discard)
minSizeRaw := fs.String("min-size", "", "minimum active member count")
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")
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
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)
}
sort := strings.TrimSpace(*sortMode)
if sort != "recent" && sort != "size" {
if sort != "recent" && sort != "oldest" && sort != "size" {
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)
minSizeRaw := fs.String("min-size", "", "minimum active member count")
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")
hideClosed := fs.Bool("hide-closed", false, "hide locally closed clusters")
jsonOut := fs.Bool("json", false, "write JSON output")
@ -1090,7 +1090,7 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
if sort == "" {
sort = "size"
}
if sort != "recent" && sort != "size" {
if sort != "recent" && sort != "oldest" && sort != "size" {
return usageErr(fmt.Errorf("unsupported sort %q", sort))
}
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.
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.
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.

View File

@ -77,6 +77,7 @@ type tuiMemberSort string
const (
memberSortKind tuiMemberSort = "kind"
memberSortRecent tuiMemberSort = "recent"
memberSortOldest tuiMemberSort = "oldest"
memberSortNumber tuiMemberSort = "number"
memberSortState tuiMemberSort = "state"
memberSortTitle tuiMemberSort = "title"
@ -1538,8 +1539,10 @@ func (m clusterBrowserModel) appendViewMenuItems(items *[]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) != "" {
@ -1678,6 +1681,12 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
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()
@ -1688,6 +1697,11 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
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
@ -2684,7 +2698,10 @@ func clusterColumns(width int, sortMode string) []table.Column {
cntTitle = "cnt*"
}
if sortMode == "recent" {
ageTitle = "age*"
ageTitle = "age-"
}
if sortMode == "oldest" {
ageTitle = "age+"
}
return []table.Column{
{Title: "id", Width: idW},
@ -2715,7 +2732,10 @@ func memberColumns(width int, sortMode tuiMemberSort) []table.Column {
stateTitle = "st*"
}
if sortMode == memberSortRecent {
ageTitle = "age*"
ageTitle = "age-"
}
if sortMode == memberSortOldest {
ageTitle = "age+"
}
if sortMode == memberSortTitle {
titleTitle = "title*"
@ -2788,6 +2808,14 @@ func (m *clusterBrowserModel) sortClusters() {
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))
@ -2798,7 +2826,11 @@ func (m *clusterBrowserModel) sortClustersFromHeader(relativeX int) {
if relativeX < columnRightEdge(columns, 1) {
m.payload.Sort = "size"
} 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" {
m.payload.Sort = "size"
} else {
@ -3184,7 +3216,11 @@ func (m *clusterBrowserModel) sortMembersFromHeader(relativeX int) {
case relativeX < columnRightEdge(columns, 1):
m.memberSort = memberSortState
case relativeX < columnRightEdge(columns, 2):
m.memberSort = memberSortRecent
if m.memberSort == memberSortRecent {
m.memberSort = memberSortOldest
} else {
m.memberSort = memberSortRecent
}
default:
if m.memberSort == memberSortTitle {
m.memberSort = memberSortKind
@ -3252,6 +3288,8 @@ func (m *clusterBrowserModel) sortMembers() {
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:
@ -3724,7 +3762,7 @@ func nextFocus(current tuiFocus, delta int) tuiFocus {
}
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 {
if item == current {
return order[(index+1)%len(order)]

View File

@ -526,6 +526,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) {
model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{
Repository: "openclaw/openclaw",

View File

@ -133,6 +133,8 @@ func (s *Store) ListRunClusterSummaries(ctx context.Context, options ClusterSumm
orderBy := `latest_updated_at desc, c.id desc`
if options.Sort == "size" {
orderBy = `c.member_count desc, c.id asc`
} else if options.Sort == "oldest" {
orderBy = `latest_updated_at asc, c.id asc`
}
limit := options.Limit
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`
if options.Sort == "size" {
orderBy = `member_count desc, cg.id asc`
} else if options.Sort == "oldest" {
orderBy = `coalesce(cg.updated_at, '') asc, cg.id asc`
}
limit := options.Limit
if limit <= 0 {
@ -377,6 +381,15 @@ func sortClusterSummaries(clusters []ClusterSummary, sortMode string) {
}
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 {
return left.UpdatedAt > right.UpdatedAt
}

View File

@ -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) {
ctx := context.Background()
st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))