Skip to content

Commit 09ef7d9

Browse files
jongioCopilot
andauthored
Add host-type pivot to session grouping (#195)
Group and pivot sessions by host type (GitHub, Azure DevOps) in the TUI, alongside the existing folder/repo/branch/date pivots. Adds the host pivot to the cycle order, a dedicated host icon, and friendly group labels at render time. Falls back safely when the store schema predates host_type. Closes #190 Co-authored-by: Copilot App <[email protected]>
1 parent 451f5ad commit 09ef7d9

12 files changed

Lines changed: 107 additions & 11 deletions

internal/config/config.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type NamedView struct {
5252
// SortOrder is the sort direction ("asc" or "desc").
5353
SortOrder string `json:"sort_order,omitempty"`
5454

55-
// Pivot is the grouping mode (e.g. "none", "folder", "repo", "branch", "date").
55+
// Pivot is the grouping mode (e.g. "none", "folder", "repo", "branch", "date", "host").
5656
Pivot string `json:"pivot,omitempty"`
5757

5858
// FavoritesOnly restricts the list to favorited sessions.
@@ -93,7 +93,7 @@ func (v *NamedView) Validate() error {
9393
}
9494
if v.Pivot != "" {
9595
switch v.Pivot {
96-
case PivotNone, PivotFolder, PivotRepo, PivotBranch, PivotDate:
96+
case PivotNone, PivotFolder, PivotRepo, PivotBranch, PivotDate, PivotHost:
9797
default:
9898
return fmt.Errorf("named view %q: invalid pivot %q", v.Name, v.Pivot)
9999
}
@@ -128,7 +128,7 @@ type Config struct {
128128
DefaultSortOrder string `json:"default_sort_order,omitempty"`
129129

130130
// DefaultPivot is the default grouping applied to session lists.
131-
// Valid values: "none", "folder", "repo", "branch", "date".
131+
// Valid values: "none", "folder", "repo", "branch", "date", "host".
132132
DefaultPivot string `json:"default_pivot"`
133133

134134
// ShowPreview controls whether the detail/preview panel is visible.
@@ -339,6 +339,7 @@ const (
339339
PivotRepo = "repo"
340340
PivotBranch = "branch"
341341
PivotDate = "date"
342+
PivotHost = "host"
342343
)
343344

344345
// EffectivePaneDirection returns the configured pane direction, defaulting

internal/data/models.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ const (
269269
PivotByBranch PivotField = "branch"
270270
// PivotByDate groups sessions by date (YYYY-MM-DD).
271271
PivotByDate PivotField = "date"
272+
// PivotByHost groups sessions by host type (the provider that created
273+
// the session, e.g. "github" or "ado").
274+
PivotByHost PivotField = "host_type"
272275
)
273276

274277
// SessionGroup holds a set of sessions that share a common pivot label.

internal/data/store.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,8 @@ func pivotExpr(p PivotField) string {
431431
return "COALESCE(s.branch, '')"
432432
case PivotByDate:
433433
return lastActiveExpr
434+
case PivotByHost:
435+
return "COALESCE(s.host_type, '')"
434436
default: // PivotByFolder and any unknown value
435437
return coalesceCwd
436438
}
@@ -1004,6 +1006,11 @@ func (s *Store) GroupSessions(ctx context.Context, pivot PivotField, filter Filt
10041006
fb.apply(s.withAutoExclusions(filter))
10051007

10061008
expr := pivotExpr(pivot)
1009+
// Older schemas (pre-v3) have no host_type column. Fall back to a single
1010+
// empty-label group rather than issuing a query that would error.
1011+
if pivot == PivotByHost && !s.hasHostType {
1012+
expr = "''"
1013+
}
10071014
q := fmt.Sprintf("SELECT %s AS pivot_label, %s FROM sessions s%s%s%s ORDER BY pivot_label, %s %s",
10081015
expr, s.sessionColumns(), countJoins, fb.joinSQL(), fb.whereSQL(), sortColumn(sort.Field), sortDir(sort.Order))
10091016

internal/data/store_coverage_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ func TestCovPivotExpr_AllFields(t *testing.T) {
304304
{PivotByRepo, "COALESCE(s.repository, '')"},
305305
{PivotByBranch, "COALESCE(s.branch, '')"},
306306
{PivotByDate, lastActiveExpr},
307+
{PivotByHost, "COALESCE(s.host_type, '')"},
307308
{PivotByFolder, "COALESCE(s.cwd, '')"},
308309
{"unknown", "COALESCE(s.cwd, '')"},
309310
}
@@ -372,7 +373,7 @@ func TestCovGroupSessions_AllPivots(t *testing.T) {
372373
defer func() { _ = s.Close() }()
373374
populateTestData(t, s)
374375

375-
pivots := []PivotField{PivotByFolder, PivotByRepo, PivotByBranch, PivotByDate}
376+
pivots := []PivotField{PivotByFolder, PivotByRepo, PivotByBranch, PivotByDate, PivotByHost}
376377
for _, p := range pivots {
377378
t.Run(string(p), func(t *testing.T) {
378379
groups, err := s.GroupSessions(context.Background(), p, FilterOptions{}, SortOptions{Field: SortByUpdated, Order: Descending}, 100)

internal/data/store_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,6 +1675,7 @@ func TestPivotExpr(t *testing.T) {
16751675
{PivotByRepo, "COALESCE(s.repository, '')"},
16761676
{PivotByBranch, "COALESCE(s.branch, '')"},
16771677
{PivotByDate, lastActiveExpr},
1678+
{PivotByHost, "COALESCE(s.host_type, '')"},
16781679
{PivotField("unknown"), "COALESCE(s.cwd, '')"}, // defaults to folder
16791680
}
16801681
for _, tt := range tests {

internal/tui/components/sessionlist.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (s *SessionList) SetGroups(groups []data.SessionGroup) {
107107
}
108108

109109
// SetPivotField stores the current pivot mode so that group header icons
110-
// reflect the active grouping dimension (folder, repo, branch, date).
110+
// reflect the active grouping dimension (folder, repo, branch, date, host).
111111
func (s *SessionList) SetPivotField(pivot string) {
112112
s.pivotField = pivot
113113
}
@@ -697,6 +697,9 @@ func (s SessionList) renderFolderRow(item displayItem, selected bool) string {
697697
}
698698

699699
folder := AbbrevHome(item.folderPath)
700+
if s.pivotField == "host" {
701+
folder = hostGroupLabel(item.folderPath)
702+
}
700703
count := strconv.Itoa(item.count)
701704

702705
prefix := " " + arrow + " "
@@ -716,6 +719,21 @@ func (s SessionList) renderFolderRow(item displayItem, selected bool) string {
716719
return styles.GroupHeaderStyle.Render(PadToWidth(line, s.width))
717720
}
718721

722+
// hostGroupLabel maps a raw host type value to a readable group label for the
723+
// host pivot. Empty values fall into a single clearly named group.
724+
func hostGroupLabel(hostType string) string {
725+
switch hostType {
726+
case "github":
727+
return "GitHub"
728+
case "ado":
729+
return "Azure DevOps"
730+
case "":
731+
return "No host"
732+
default:
733+
return hostType
734+
}
735+
}
736+
719737
func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden bool, aiFound bool, favorited bool) string {
720738
w := s.width
721739
if w <= 0 {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package components
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/jongio/dispatch/internal/data"
8+
)
9+
10+
func TestHostGroupLabel(t *testing.T) {
11+
tests := []struct {
12+
in string
13+
want string
14+
}{
15+
{"github", "GitHub"},
16+
{"ado", "Azure DevOps"},
17+
{"", "No host"},
18+
{"other", "other"},
19+
}
20+
for _, tt := range tests {
21+
if got := hostGroupLabel(tt.in); got != tt.want {
22+
t.Errorf("hostGroupLabel(%q) = %q, want %q", tt.in, got, tt.want)
23+
}
24+
}
25+
}
26+
27+
func TestHostPivotRendersFriendlyLabels(t *testing.T) {
28+
sl := NewSessionList()
29+
sl.SetPivotField("host")
30+
sl.SetGroups([]data.SessionGroup{
31+
{Label: "github", Count: 2, Sessions: []data.Session{
32+
{ID: "1", HostType: "github"},
33+
{ID: "2", HostType: "github"},
34+
}},
35+
{Label: "", Count: 1, Sessions: []data.Session{
36+
{ID: "3"},
37+
}},
38+
})
39+
sl.SetSize(80, 20)
40+
41+
view := sl.View()
42+
if !strings.Contains(view, "GitHub") {
43+
t.Errorf("expected view to contain friendly host label 'GitHub'\n%s", view)
44+
}
45+
if !strings.Contains(view, "No host") {
46+
t.Errorf("expected view to contain 'No host' for empty host type\n%s", view)
47+
}
48+
}

internal/tui/model.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const (
137137
pivotRepo = "repo"
138138
pivotBranch = "branch"
139139
pivotDate = "date"
140+
pivotHost = "host"
140141
)
141142

142143
// ---------------------------------------------------------------------------
@@ -199,7 +200,7 @@ type Model struct {
199200
filter data.FilterOptions
200201
sort data.SortOptions
201202
timeRange string // "1h", "1d", "7d", "all"
202-
pivot string // "none", "folder", "repo", "branch", "date"
203+
pivot string // "none", "folder", "repo", "branch", "date", "host"
203204
pivotOrder data.SortOrder // group header sort direction
204205

205206
// Loaded data.
@@ -2858,7 +2859,7 @@ func (m *Model) toggleSortOrder() {
28582859
m.saveConfig()
28592860
}
28602861

2861-
var pivotModes = []string{pivotNone, pivotFolder, pivotRepo, pivotBranch, pivotDate}
2862+
var pivotModes = []string{pivotNone, pivotFolder, pivotRepo, pivotBranch, pivotDate, pivotHost}
28622863

28632864
func (m *Model) cyclePivot() {
28642865
for i, p := range pivotModes {
@@ -4012,6 +4013,8 @@ func pivotFieldFromString(s string) data.PivotField {
40124013
return data.PivotByBranch
40134014
case pivotDate:
40144015
return data.PivotByDate
4016+
case pivotHost:
4017+
return data.PivotByHost
40154018
default:
40164019
return data.PivotByFolder
40174020
}

internal/tui/model_coverage_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,8 +497,8 @@ func TestCovCyclePivotFullCycle(t *testing.T) {
497497
m := newTestModel()
498498
m.pivot = pivotNone
499499

500-
// pivotModes = [none, folder, repo, branch, date]
501-
expected := []string{pivotFolder, pivotRepo, pivotBranch, pivotDate, pivotNone}
500+
// pivotModes = [none, folder, repo, branch, date, host]
501+
expected := []string{pivotFolder, pivotRepo, pivotBranch, pivotDate, pivotHost, pivotNone}
502502
for _, exp := range expected {
503503
m.cyclePivot()
504504
if m.pivot != exp {

internal/tui/model_helpers_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func TestPivotFieldFromString(t *testing.T) {
5656
{"repo", data.PivotByRepo},
5757
{"branch", data.PivotByBranch},
5858
{"date", data.PivotByDate},
59+
{"host", data.PivotByHost},
5960
{"none", data.PivotByFolder}, // unknown → folder
6061
{"", data.PivotByFolder},
6162
{"unknown", data.PivotByFolder},
@@ -755,7 +756,7 @@ func TestCyclePivot(t *testing.T) {
755756
m := newTestModel()
756757
m.pivot = pivotNone
757758

758-
expected := []string{pivotFolder, pivotRepo, pivotBranch, pivotDate, pivotNone}
759+
expected := []string{pivotFolder, pivotRepo, pivotBranch, pivotDate, pivotHost, pivotNone}
759760
for _, exp := range expected {
760761
m.cyclePivot()
761762
if m.pivot != exp {

0 commit comments

Comments
 (0)