detached e46f9aa

Merge pull request #146 from bnema/feat/routes-tree-view

feat: replace routes list table with tree view
commit
e46f9aa0d4b57e960b9a196d335faca15dd0e6d9
author
bnema · · 2026-03-24 06:25 UTC
committer
GitHub · · 2026-03-24 06:25 UTC
merge of
372b09b + 491f951 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +534 -133
M internal/adapters/in/cli/routes.go +138 -83
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "sort"
89 "strings"
910
1011 "github.com/spf13/cobra"
@@ -55,20 +56,71 @@ func truncateImage(image string, maxLen int) string {
5556 return image[:maxLen-3] + "..."
5657 }
5758
58-func truncateNetwork(network string, maxLen int) string {
59- if network == "" || network == "-" {
60- return "-"
59+const networkPrefix = "gordon-"
60+
61+// httpHealthToStatus maps an HTTP health probe result to a components.Status.
62+func httpHealthToStatus(health *remote.RouteHealth) components.Status {
63+ if health == nil {
64+ return components.StatusUnknown
6165 }
62- if maxLen <= 0 {
63- return ""
66+ if health.HTTPStatus == 0 {
67+ if health.Error != "" {
68+ return components.StatusError
69+ }
70+ return components.StatusUnknown
6471 }
65- if len(network) <= maxLen {
66- return network
72+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
73+ return components.StatusSuccess
6774 }
68- if maxLen <= 3 {
69- return network[:maxLen]
75+ return components.StatusError
76+}
77+
78+// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
79+func stripNetworkPrefix(network string) string {
80+ return strings.TrimPrefix(network, networkPrefix)
81+}
82+
83+// networkGroup holds routes that share a network.
84+type networkGroup struct {
85+ name string
86+ routes []remote.RouteInfo
87+}
88+
89+// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90+// and solo routes. Both are sorted alphabetically by domain.
91+func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
92+ byNetwork := make(map[string][]remote.RouteInfo)
93+ var networkOrder []string
94+ for _, route := range routes {
95+ if _, seen := byNetwork[route.Network]; !seen {
96+ networkOrder = append(networkOrder, route.Network)
97+ }
98+ byNetwork[route.Network] = append(byNetwork[route.Network], route)
99+ }
100+
101+ var groups []networkGroup
102+ var solo []remote.RouteInfo
103+
104+ for _, net := range networkOrder {
105+ members := byNetwork[net]
106+ if len(members) >= 2 {
107+ sort.Slice(members, func(i, j int) bool {
108+ return members[i].Domain < members[j].Domain
109+ })
110+ groups = append(groups, networkGroup{
111+ name: stripNetworkPrefix(net),
112+ routes: members,
113+ })
114+ } else {
115+ solo = append(solo, members[0])
116+ }
70117 }
71- return network[:maxLen-3] + "..."
118+
119+ sort.Slice(solo, func(i, j int) bool {
120+ return solo[i].Domain < solo[j].Domain
121+ })
122+
123+ return groups, solo
72124 }
73125
74126 // newRoutesCmd creates the routes command group.
@@ -84,6 +136,13 @@ the local Gordon configuration.`,
84136 }
85137
86138 cmd.AddCommand(newRoutesListCmd())
139+
140+ // "status" is an alias for "list"
141+ statusCmd := newRoutesListCmd()
142+ statusCmd.Use = "status"
143+ statusCmd.Short = "Show status of all routes (alias for list)"
144+ cmd.AddCommand(statusCmd)
145+
87146 cmd.AddCommand(newRoutesShowCmd())
88147 cmd.AddCommand(newRoutesAddCmd())
89148 cmd.AddCommand(newRoutesRemoveCmd())
@@ -91,27 +150,6 @@ the local Gordon configuration.`,
91150 return cmd
92151 }
93152
94-// formatHTTPStatus formats the HTTP status for display.
95-func formatHTTPStatus(health *remote.RouteHealth) string {
96- if health == nil {
97- return styles.Theme.Muted.Render("-")
98- }
99- if health.HTTPStatus == 0 {
100- if health.Error != "" {
101- return styles.Theme.BadgeError.Render("err")
102- }
103- return styles.Theme.Muted.Render("-")
104- }
105- status := fmt.Sprintf("%d", health.HTTPStatus)
106- if health.ResponseTimeMs > 0 {
107- status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
108- }
109- if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
110- return styles.Theme.BadgeSuccess.Render(status)
111- }
112- return styles.Theme.BadgeError.Render(status)
113-}
114-
115153 // newRoutesListCmd creates the routes list command.
116154 func newRoutesListCmd() *cobra.Command {
117155 var jsonOut bool
@@ -120,7 +158,7 @@ func newRoutesListCmd() *cobra.Command {
120158 Use: "list",
121159 Short: "List all routes",
122160 RunE: func(cmd *cobra.Command, args []string) error {
123- ctx := context.Background()
161+ ctx := cmd.Context()
124162
125163 client, isRemote := GetRemoteClient()
126164 if isRemote {
@@ -228,7 +266,6 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
228266 return fmt.Errorf("failed to list routes: %w", err)
229267 }
230268
231- // Get health status for each route (includes container status and HTTP probe)
232269 health, _ := client.GetHealth(ctx)
233270 if health == nil {
234271 health = make(map[string]*remote.RouteHealth)
@@ -243,66 +280,84 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
243280 return nil
244281 }
245282
246- const imageColWidth = 35
247- const networkColWidth = 22
248- rows := make([][]string, 0, len(routes))
249- for _, route := range routes {
250- routeHealth := health[route.Domain]
283+ groups, solo := groupRoutesByNetwork(routes)
251284
252- // Container status column
253- containerStatus := route.ContainerStatus
254- if containerStatus == "" {
255- if routeHealth != nil {
256- containerStatus = routeHealth.ContainerStatus
257- } else {
258- containerStatus = "unknown"
285+ type sortableItem struct {
286+ sortKey string
287+ group *networkGroup
288+ route *remote.RouteInfo
289+ }
290+
291+ var items []sortableItem
292+ for i := range groups {
293+ items = append(items, sortableItem{
294+ sortKey: groups[i].routes[0].Domain,
295+ group: &groups[i],
296+ })
297+ }
298+ for i := range solo {
299+ items = append(items, sortableItem{
300+ sortKey: solo[i].Domain,
301+ route: &solo[i],
302+ })
303+ }
304+ sort.Slice(items, func(i, j int) bool {
305+ return items[i].sortKey < items[j].sortKey
306+ })
307+
308+ tree := components.NewTree()
309+
310+ for _, item := range items {
311+ if item.group != nil {
312+ g := tree.AddGroup(item.group.name)
313+ for _, route := range item.group.routes {
314+ title := routeTitle(route, health)
315+ node := g.AddNode(title, route.Image)
316+ addAttachmentChildren(node, route)
259317 }
318+ } else {
319+ title := routeTitle(*item.route, health)
320+ node := tree.AddNode(title, item.route.Image)
321+ addAttachmentChildren(node, *item.route)
260322 }
261- containerBadge := components.ContainerStatusBadge(containerStatus)
323+ }
262324
263- // HTTP status column
264- httpStatus := formatHTTPStatus(routeHealth)
325+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
326+ return err
327+ }
328+ if err := cliWriteLine(out, ""); err != nil {
329+ return err
330+ }
331+ return cliWriteLine(out, tree.Render())
332+}
265333
266- displayImage := truncateImage(route.Image, imageColWidth)
267- displayNetwork := truncateNetwork(route.Network, networkColWidth)
268- rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
269-
270- for i, attachment := range route.Attachments {
271- // Unicode box-drawing tree structure for nested attachments
272- prefix := styles.IconTreeBranch + styles.IconTreeLine
273- if i == len(route.Attachments)-1 {
274- prefix = styles.IconTreeLast + styles.IconTreeLine
275- }
276- attachmentName := prefix + " " + attachment.Name
277- runes := []rune(attachmentName)
278- if len(runes) > 25 {
279- attachmentName = string(runes[:22]) + "..."
280- }
281- attachmentStatus := components.ContainerStatusBadge(attachment.Status)
282- attachmentImage := truncateImage(attachment.Image, imageColWidth)
283- // Display the attachment's actual network in the routes table
284- attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
285- rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
334+// routeTitle builds the pre-styled title line for a route node.
335+func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
336+ containerStatus := route.ContainerStatus
337+ if containerStatus == "" {
338+ if h := health[route.Domain]; h != nil {
339+ containerStatus = h.ContainerStatus
340+ } else {
341+ containerStatus = "unknown"
286342 }
287343 }
288344
289- // Render table
290- table := components.NewTable(
291- components.WithColumns([]components.TableColumn{
292- {Title: "Domain", Width: 25},
293- {Title: "Image", Width: imageColWidth},
294- {Title: "Network", Width: networkColWidth},
295- {Title: "Container", Width: 12},
296- {Title: "HTTP", Width: 14},
297- }),
298- components.WithRows(rows),
299- )
345+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
346+ containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
300347
301- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
302- _, _ = fmt.Fprintln(out)
303- _, _ = fmt.Fprintln(out, table.View())
348+ return httpIcon + " " + containerIcon + " " + route.Domain
349+}
304350
305- return nil
351+// addAttachmentChildren adds attachment nodes as children of a route node.
352+func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
353+ for _, att := range route.Attachments {
354+ attStatus := att.Status
355+ if attStatus == "" {
356+ attStatus = "unknown"
357+ }
358+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
359+ node.AddChild(attIcon+" "+att.Name, att.Image)
360+ }
306361 }
307362
308363 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "sort"
8 "strings"9 "strings"
9 10
10 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
@@ -55,20 +56,71 @@ func truncateImage(image string, maxLen int) string {
55 return image[:maxLen-3] + "..."56 return image[:maxLen-3] + "..."
56 }57 }
57 58
58-func truncateNetwork(network string, maxLen int) string {59+const networkPrefix = "gordon-"
59- if network == "" || network == "-" {60+
60- return "-"61+// httpHealthToStatus maps an HTTP health probe result to a components.Status.
62+func httpHealthToStatus(health *remote.RouteHealth) components.Status {
63+ if health == nil {
64+ return components.StatusUnknown
61 }65 }
62- if maxLen <= 0 {66+ if health.HTTPStatus == 0 {
63- return ""67+ if health.Error != "" {
68+ return components.StatusError
69+ }
70+ return components.StatusUnknown
64 }71 }
65- if len(network) <= maxLen {72+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
66- return network73+ return components.StatusSuccess
67 }74 }
68- if maxLen <= 3 {75+ return components.StatusError
69- return network[:maxLen]76+}
77+
78+// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
79+func stripNetworkPrefix(network string) string {
80+ return strings.TrimPrefix(network, networkPrefix)
81+}
82+
83+// networkGroup holds routes that share a network.
84+type networkGroup struct {
85+ name string
86+ routes []remote.RouteInfo
87+}
88+
89+// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90+// and solo routes. Both are sorted alphabetically by domain.
91+func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
92+ byNetwork := make(map[string][]remote.RouteInfo)
93+ var networkOrder []string
94+ for _, route := range routes {
95+ if _, seen := byNetwork[route.Network]; !seen {
96+ networkOrder = append(networkOrder, route.Network)
97+ }
98+ byNetwork[route.Network] = append(byNetwork[route.Network], route)
99+ }
100+
101+ var groups []networkGroup
102+ var solo []remote.RouteInfo
103+
104+ for _, net := range networkOrder {
105+ members := byNetwork[net]
106+ if len(members) >= 2 {
107+ sort.Slice(members, func(i, j int) bool {
108+ return members[i].Domain < members[j].Domain
109+ })
110+ groups = append(groups, networkGroup{
111+ name: stripNetworkPrefix(net),
112+ routes: members,
113+ })
114+ } else {
115+ solo = append(solo, members[0])
116+ }
70 }117 }
71- return network[:maxLen-3] + "..."118+
119+ sort.Slice(solo, func(i, j int) bool {
120+ return solo[i].Domain < solo[j].Domain
121+ })
122+
123+ return groups, solo
72 }124 }
73 125
74 // newRoutesCmd creates the routes command group.126 // newRoutesCmd creates the routes command group.
@@ -84,6 +136,13 @@ the local Gordon configuration.`,
84 }136 }
85 137
86 cmd.AddCommand(newRoutesListCmd())138 cmd.AddCommand(newRoutesListCmd())
139+
140+ // "status" is an alias for "list"
141+ statusCmd := newRoutesListCmd()
142+ statusCmd.Use = "status"
143+ statusCmd.Short = "Show status of all routes (alias for list)"
144+ cmd.AddCommand(statusCmd)
145+
87 cmd.AddCommand(newRoutesShowCmd())146 cmd.AddCommand(newRoutesShowCmd())
88 cmd.AddCommand(newRoutesAddCmd())147 cmd.AddCommand(newRoutesAddCmd())
89 cmd.AddCommand(newRoutesRemoveCmd())148 cmd.AddCommand(newRoutesRemoveCmd())
@@ -91,27 +150,6 @@ the local Gordon configuration.`,
91 return cmd150 return cmd
92 }151 }
93 152
94-// formatHTTPStatus formats the HTTP status for display.
95-func formatHTTPStatus(health *remote.RouteHealth) string {
96- if health == nil {
97- return styles.Theme.Muted.Render("-")
98- }
99- if health.HTTPStatus == 0 {
100- if health.Error != "" {
101- return styles.Theme.BadgeError.Render("err")
102- }
103- return styles.Theme.Muted.Render("-")
104- }
105- status := fmt.Sprintf("%d", health.HTTPStatus)
106- if health.ResponseTimeMs > 0 {
107- status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
108- }
109- if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
110- return styles.Theme.BadgeSuccess.Render(status)
111- }
112- return styles.Theme.BadgeError.Render(status)
113-}
114-
115 // newRoutesListCmd creates the routes list command.153 // newRoutesListCmd creates the routes list command.
116 func newRoutesListCmd() *cobra.Command {154 func newRoutesListCmd() *cobra.Command {
117 var jsonOut bool155 var jsonOut bool
@@ -120,7 +158,7 @@ func newRoutesListCmd() *cobra.Command {
120 Use: "list",158 Use: "list",
121 Short: "List all routes",159 Short: "List all routes",
122 RunE: func(cmd *cobra.Command, args []string) error {160 RunE: func(cmd *cobra.Command, args []string) error {
123- ctx := context.Background()161+ ctx := cmd.Context()
124 162
125 client, isRemote := GetRemoteClient()163 client, isRemote := GetRemoteClient()
126 if isRemote {164 if isRemote {
@@ -228,7 +266,6 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
228 return fmt.Errorf("failed to list routes: %w", err)266 return fmt.Errorf("failed to list routes: %w", err)
229 }267 }
230 268
231- // Get health status for each route (includes container status and HTTP probe)
232 health, _ := client.GetHealth(ctx)269 health, _ := client.GetHealth(ctx)
233 if health == nil {270 if health == nil {
234 health = make(map[string]*remote.RouteHealth)271 health = make(map[string]*remote.RouteHealth)
@@ -243,66 +280,84 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
243 return nil280 return nil
244 }281 }
245 282
246- const imageColWidth = 35283+ groups, solo := groupRoutesByNetwork(routes)
247- const networkColWidth = 22
248- rows := make([][]string, 0, len(routes))
249- for _, route := range routes {
250- routeHealth := health[route.Domain]
251 284
252- // Container status column285+ type sortableItem struct {
253- containerStatus := route.ContainerStatus286+ sortKey string
254- if containerStatus == "" {287+ group *networkGroup
255- if routeHealth != nil {288+ route *remote.RouteInfo
256- containerStatus = routeHealth.ContainerStatus289+ }
257- } else {290+
258- containerStatus = "unknown"291+ var items []sortableItem
292+ for i := range groups {
293+ items = append(items, sortableItem{
294+ sortKey: groups[i].routes[0].Domain,
295+ group: &groups[i],
296+ })
297+ }
298+ for i := range solo {
299+ items = append(items, sortableItem{
300+ sortKey: solo[i].Domain,
301+ route: &solo[i],
302+ })
303+ }
304+ sort.Slice(items, func(i, j int) bool {
305+ return items[i].sortKey < items[j].sortKey
306+ })
307+
308+ tree := components.NewTree()
309+
310+ for _, item := range items {
311+ if item.group != nil {
312+ g := tree.AddGroup(item.group.name)
313+ for _, route := range item.group.routes {
314+ title := routeTitle(route, health)
315+ node := g.AddNode(title, route.Image)
316+ addAttachmentChildren(node, route)
259 }317 }
318+ } else {
319+ title := routeTitle(*item.route, health)
320+ node := tree.AddNode(title, item.route.Image)
321+ addAttachmentChildren(node, *item.route)
260 }322 }
261- containerBadge := components.ContainerStatusBadge(containerStatus)323+ }
262 324
263- // HTTP status column325+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
264- httpStatus := formatHTTPStatus(routeHealth)326+ return err
327+ }
328+ if err := cliWriteLine(out, ""); err != nil {
329+ return err
330+ }
331+ return cliWriteLine(out, tree.Render())
332+}
265 333
266- displayImage := truncateImage(route.Image, imageColWidth)334+// routeTitle builds the pre-styled title line for a route node.
267- displayNetwork := truncateNetwork(route.Network, networkColWidth)335+func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
268- rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})336+ containerStatus := route.ContainerStatus
269-337+ if containerStatus == "" {
270- for i, attachment := range route.Attachments {338+ if h := health[route.Domain]; h != nil {
271- // Unicode box-drawing tree structure for nested attachments339+ containerStatus = h.ContainerStatus
272- prefix := styles.IconTreeBranch + styles.IconTreeLine340+ } else {
273- if i == len(route.Attachments)-1 {341+ containerStatus = "unknown"
274- prefix = styles.IconTreeLast + styles.IconTreeLine
275- }
276- attachmentName := prefix + " " + attachment.Name
277- runes := []rune(attachmentName)
278- if len(runes) > 25 {
279- attachmentName = string(runes[:22]) + "..."
280- }
281- attachmentStatus := components.ContainerStatusBadge(attachment.Status)
282- attachmentImage := truncateImage(attachment.Image, imageColWidth)
283- // Display the attachment's actual network in the routes table
284- attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
285- rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
286 }342 }
287 }343 }
288 344
289- // Render table345+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
290- table := components.NewTable(346+ containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
291- components.WithColumns([]components.TableColumn{
292- {Title: "Domain", Width: 25},
293- {Title: "Image", Width: imageColWidth},
294- {Title: "Network", Width: networkColWidth},
295- {Title: "Container", Width: 12},
296- {Title: "HTTP", Width: 14},
297- }),
298- components.WithRows(rows),
299- )
300 347
301- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))348+ return httpIcon + " " + containerIcon + " " + route.Domain
302- _, _ = fmt.Fprintln(out)349+}
303- _, _ = fmt.Fprintln(out, table.View())
304 350
305- return nil351+// addAttachmentChildren adds attachment nodes as children of a route node.
352+func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
353+ for _, att := range route.Attachments {
354+ attStatus := att.Status
355+ if attStatus == "" {
356+ attStatus = "unknown"
357+ }
358+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
359+ node.AddChild(attIcon+" "+att.Name, att.Image)
360+ }
306 }361 }
307 362
308 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {363 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
M internal/adapters/in/cli/routes_test.go +68 -50
@@ -3,6 +3,8 @@ package cli
33 import (
44 "testing"
55
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
68 "github.com/stretchr/testify/assert"
79 )
810
@@ -102,80 +104,96 @@ func TestTruncateImage(t *testing.T) {
102104 }
103105 }
104106
105-func TestTruncateNetwork(t *testing.T) {
107+func TestHTTPHealthToStatus(t *testing.T) {
106108 tests := []struct {
107109 name string
108- network string
109- maxLen int
110- expected string
110+ health *remote.RouteHealth
111+ expected components.Status
111112 }{
112- // Basic cases
113113 {
114- name: "short network fits",
115- network: "gordon-net",
116- maxLen: 20,
117- expected: "gordon-net",
114+ name: "nil health returns unknown",
115+ health: nil,
116+ expected: components.StatusUnknown,
118117 },
119118 {
120- name: "exact fit",
121- network: "gordon-net",
122- maxLen: 10,
123- expected: "gordon-net",
119+ name: "zero status no error returns unknown",
120+ health: &remote.RouteHealth{HTTPStatus: 0},
121+ expected: components.StatusUnknown,
124122 },
125-
126- // Truncation
127123 {
128- name: "truncate long network with ellipsis",
129- network: "gordon-my-very-long-network-name",
130- maxLen: 20,
131- expected: "gordon-my-very-lo...",
124+ name: "zero status with error returns error",
125+ health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
126+ expected: components.StatusError,
132127 },
133-
134- // Special values
135128 {
136- name: "empty returns dash",
137- network: "",
138- maxLen: 10,
139- expected: "-",
129+ name: "200 returns success",
130+ health: &remote.RouteHealth{HTTPStatus: 200},
131+ expected: components.StatusSuccess,
140132 },
141133 {
142- name: "dash returns dash",
143- network: "-",
144- maxLen: 10,
145- expected: "-",
146- },
147-
148- // Edge cases
149- {
150- name: "maxLen zero returns empty",
151- network: "gordon-net",
152- maxLen: 0,
153- expected: "",
134+ name: "301 returns success",
135+ health: &remote.RouteHealth{HTTPStatus: 301},
136+ expected: components.StatusSuccess,
154137 },
155138 {
156- name: "maxLen negative returns empty",
157- network: "gordon-net",
158- maxLen: -1,
159- expected: "",
139+ name: "500 returns error",
140+ health: &remote.RouteHealth{HTTPStatus: 500},
141+ expected: components.StatusError,
160142 },
161143 {
162- name: "maxLen 3 or less - no ellipsis",
163- network: "gordon-net",
164- maxLen: 3,
165- expected: "gor",
144+ name: "404 returns error",
145+ health: &remote.RouteHealth{HTTPStatus: 404},
146+ expected: components.StatusError,
166147 },
167148 {
168- name: "maxLen 4 - truncate with ellipsis",
169- network: "gordon-net",
170- maxLen: 4,
171- expected: "g...",
149+ name: "100 informational returns error",
150+ health: &remote.RouteHealth{HTTPStatus: 100},
151+ expected: components.StatusError,
172152 },
173153 }
174154
175155 for _, tt := range tests {
176156 t.Run(tt.name, func(t *testing.T) {
177- result := truncateNetwork(tt.network, tt.maxLen)
157+ result := httpHealthToStatus(tt.health)
178158 assert.Equal(t, tt.expected, result)
179159 })
180160 }
181161 }
162+
163+func TestGroupRoutesByNetwork(t *testing.T) {
164+ routes := []remote.RouteInfo{
165+ {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
166+ {Domain: "beta.dev", Network: "gordon-shared"},
167+ {Domain: "gamma.dev", Network: "gordon-shared"},
168+ {Domain: "delta.dev", Network: "gordon-delta-dev"},
169+ }
170+
171+ groups, solo := groupRoutesByNetwork(routes)
172+
173+ assert.Len(t, solo, 2)
174+ assert.Equal(t, "alpha.dev", solo[0].Domain)
175+ assert.Equal(t, "delta.dev", solo[1].Domain)
176+
177+ assert.Len(t, groups, 1)
178+ assert.Equal(t, "shared", groups[0].name)
179+ assert.Len(t, groups[0].routes, 2)
180+}
181+
182+func TestStripNetworkPrefix(t *testing.T) {
183+ tests := []struct {
184+ network string
185+ expected string
186+ }{
187+ {"gordon-shared-services", "shared-services"},
188+ {"gordon-my-app-dev", "my-app-dev"},
189+ {"custom-network", "custom-network"},
190+ {"gordon-", ""},
191+ {"", ""},
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.network, func(t *testing.T) {
196+ assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
197+ })
198+ }
199+}
@@ -3,6 +3,8 @@ package cli
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
6 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
7 )9 )
8 10
@@ -102,80 +104,96 @@ func TestTruncateImage(t *testing.T) {
102 }104 }
103 }105 }
104 106
105-func TestTruncateNetwork(t *testing.T) {107+func TestHTTPHealthToStatus(t *testing.T) {
106 tests := []struct {108 tests := []struct {
107 name string109 name string
108- network string110+ health *remote.RouteHealth
109- maxLen int111+ expected components.Status
110- expected string
111 }{112 }{
112- // Basic cases
113 {113 {
114- name: "short network fits",114+ name: "nil health returns unknown",
115- network: "gordon-net",115+ health: nil,
116- maxLen: 20,116+ expected: components.StatusUnknown,
117- expected: "gordon-net",
118 },117 },
119 {118 {
120- name: "exact fit",119+ name: "zero status no error returns unknown",
121- network: "gordon-net",120+ health: &remote.RouteHealth{HTTPStatus: 0},
122- maxLen: 10,121+ expected: components.StatusUnknown,
123- expected: "gordon-net",
124 },122 },
125-
126- // Truncation
127 {123 {
128- name: "truncate long network with ellipsis",124+ name: "zero status with error returns error",
129- network: "gordon-my-very-long-network-name",125+ health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
130- maxLen: 20,126+ expected: components.StatusError,
131- expected: "gordon-my-very-lo...",
132 },127 },
133-
134- // Special values
135 {128 {
136- name: "empty returns dash",129+ name: "200 returns success",
137- network: "",130+ health: &remote.RouteHealth{HTTPStatus: 200},
138- maxLen: 10,131+ expected: components.StatusSuccess,
139- expected: "-",
140 },132 },
141 {133 {
142- name: "dash returns dash",134+ name: "301 returns success",
143- network: "-",135+ health: &remote.RouteHealth{HTTPStatus: 301},
144- maxLen: 10,136+ expected: components.StatusSuccess,
145- expected: "-",
146- },
147-
148- // Edge cases
149- {
150- name: "maxLen zero returns empty",
151- network: "gordon-net",
152- maxLen: 0,
153- expected: "",
154 },137 },
155 {138 {
156- name: "maxLen negative returns empty",139+ name: "500 returns error",
157- network: "gordon-net",140+ health: &remote.RouteHealth{HTTPStatus: 500},
158- maxLen: -1,141+ expected: components.StatusError,
159- expected: "",
160 },142 },
161 {143 {
162- name: "maxLen 3 or less - no ellipsis",144+ name: "404 returns error",
163- network: "gordon-net",145+ health: &remote.RouteHealth{HTTPStatus: 404},
164- maxLen: 3,146+ expected: components.StatusError,
165- expected: "gor",
166 },147 },
167 {148 {
168- name: "maxLen 4 - truncate with ellipsis",149+ name: "100 informational returns error",
169- network: "gordon-net",150+ health: &remote.RouteHealth{HTTPStatus: 100},
170- maxLen: 4,151+ expected: components.StatusError,
171- expected: "g...",
172 },152 },
173 }153 }
174 154
175 for _, tt := range tests {155 for _, tt := range tests {
176 t.Run(tt.name, func(t *testing.T) {156 t.Run(tt.name, func(t *testing.T) {
177- result := truncateNetwork(tt.network, tt.maxLen)157+ result := httpHealthToStatus(tt.health)
178 assert.Equal(t, tt.expected, result)158 assert.Equal(t, tt.expected, result)
179 })159 })
180 }160 }
181 }161 }
162+
163+func TestGroupRoutesByNetwork(t *testing.T) {
164+ routes := []remote.RouteInfo{
165+ {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
166+ {Domain: "beta.dev", Network: "gordon-shared"},
167+ {Domain: "gamma.dev", Network: "gordon-shared"},
168+ {Domain: "delta.dev", Network: "gordon-delta-dev"},
169+ }
170+
171+ groups, solo := groupRoutesByNetwork(routes)
172+
173+ assert.Len(t, solo, 2)
174+ assert.Equal(t, "alpha.dev", solo[0].Domain)
175+ assert.Equal(t, "delta.dev", solo[1].Domain)
176+
177+ assert.Len(t, groups, 1)
178+ assert.Equal(t, "shared", groups[0].name)
179+ assert.Len(t, groups[0].routes, 2)
180+}
181+
182+func TestStripNetworkPrefix(t *testing.T) {
183+ tests := []struct {
184+ network string
185+ expected string
186+ }{
187+ {"gordon-shared-services", "shared-services"},
188+ {"gordon-my-app-dev", "my-app-dev"},
189+ {"custom-network", "custom-network"},
190+ {"gordon-", ""},
191+ {"", ""},
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.network, func(t *testing.T) {
196+ assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
197+ })
198+ }
199+}
M internal/adapters/in/cli/ui/components/status.go +7 -0
@@ -188,3 +188,10 @@ func ContainerStatusBadge(status string) string {
188188 parsed := ParseStatus(status)
189189 return RenderStatusBadge(parsed)
190190 }
191+
192+// StatusIcon renders an icon string colored by status.
193+// Green for success/running, red for error/stopped/exited, muted for unknown/pending.
194+func StatusIcon(icon string, status Status) string {
195+ config := DefaultStatusConfigs[status]
196+ return config.Style.Render(icon)
197+}
@@ -188,3 +188,10 @@ func ContainerStatusBadge(status string) string {
188 parsed := ParseStatus(status)188 parsed := ParseStatus(status)
189 return RenderStatusBadge(parsed)189 return RenderStatusBadge(parsed)
190 }190 }
191+
192+// StatusIcon renders an icon string colored by status.
193+// Green for success/running, red for error/stopped/exited, muted for unknown/pending.
194+func StatusIcon(icon string, status Status) string {
195+ config := DefaultStatusConfigs[status]
196+ return config.Style.Render(icon)
197+}
A internal/adapters/in/cli/ui/components/status_test.go +52 -0
@@ -0,0 +1,52 @@
1+package components
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/muesli/termenv"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func TestMain(m *testing.M) {
14+ // Force TrueColor so lipgloss renders ANSI color codes in tests
15+ // (without a TTY, lipgloss strips all styling).
16+ lipgloss.SetColorProfile(termenv.TrueColor)
17+ os.Exit(m.Run())
18+}
19+
20+func TestStatusIcon_ReturnsStyledIcon(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ icon string
24+ status Status
25+ }{
26+ {"success renders without error", styles.IconContainerStatus, StatusSuccess},
27+ {"running renders without error", styles.IconContainerStatus, StatusRunning},
28+ {"error renders without error", styles.IconHTTPStatus, StatusError},
29+ {"stopped renders without error", styles.IconContainerStatus, StatusStopped},
30+ {"exited renders without error", styles.IconContainerStatus, StatusExited},
31+ {"unknown renders without error", styles.IconHTTPStatus, StatusUnknown},
32+ {"pending renders without error", styles.IconHTTPStatus, StatusPending},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ result := StatusIcon(tt.icon, tt.status)
38+ assert.NotEmpty(t, result)
39+ assert.Contains(t, result, tt.icon)
40+ })
41+ }
42+}
43+
44+func TestStatusIcon_DifferentStatusesProduceDifferentOutput(t *testing.T) {
45+ success := StatusIcon(styles.IconContainerStatus, StatusRunning)
46+ failure := StatusIcon(styles.IconContainerStatus, StatusError)
47+ unknown := StatusIcon(styles.IconContainerStatus, StatusUnknown)
48+
49+ assert.NotEqual(t, success, failure)
50+ assert.NotEqual(t, success, unknown)
51+ assert.NotEqual(t, failure, unknown)
52+}
@@ -0,0 +1,52 @@
1+package components
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/muesli/termenv"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func TestMain(m *testing.M) {
14+ // Force TrueColor so lipgloss renders ANSI color codes in tests
15+ // (without a TTY, lipgloss strips all styling).
16+ lipgloss.SetColorProfile(termenv.TrueColor)
17+ os.Exit(m.Run())
18+}
19+
20+func TestStatusIcon_ReturnsStyledIcon(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ icon string
24+ status Status
25+ }{
26+ {"success renders without error", styles.IconContainerStatus, StatusSuccess},
27+ {"running renders without error", styles.IconContainerStatus, StatusRunning},
28+ {"error renders without error", styles.IconHTTPStatus, StatusError},
29+ {"stopped renders without error", styles.IconContainerStatus, StatusStopped},
30+ {"exited renders without error", styles.IconContainerStatus, StatusExited},
31+ {"unknown renders without error", styles.IconHTTPStatus, StatusUnknown},
32+ {"pending renders without error", styles.IconHTTPStatus, StatusPending},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ result := StatusIcon(tt.icon, tt.status)
38+ assert.NotEmpty(t, result)
39+ assert.Contains(t, result, tt.icon)
40+ })
41+ }
42+}
43+
44+func TestStatusIcon_DifferentStatusesProduceDifferentOutput(t *testing.T) {
45+ success := StatusIcon(styles.IconContainerStatus, StatusRunning)
46+ failure := StatusIcon(styles.IconContainerStatus, StatusError)
47+ unknown := StatusIcon(styles.IconContainerStatus, StatusUnknown)
48+
49+ assert.NotEqual(t, success, failure)
50+ assert.NotEqual(t, success, unknown)
51+ assert.NotEqual(t, failure, unknown)
52+}
A internal/adapters/in/cli/ui/components/tree.go +142 -0
@@ -0,0 +1,142 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
7+)
8+
9+// Node represents a tree node with a title, optional subtitle, and children.
10+type Node struct {
11+ Title string
12+ Subtitle string
13+ Children []*Node
14+}
15+
16+// AddChild adds a child node under this node.
17+func (n *Node) AddChild(title, subtitle string) *Node {
18+ child := &Node{Title: title, Subtitle: subtitle}
19+ n.Children = append(n.Children, child)
20+ return child
21+}
22+
23+// Group represents a named group of nodes rendered with a vertical bar connector.
24+type Group struct {
25+ Name string
26+ Nodes []*Node
27+}
28+
29+// AddNode adds a node inside this group.
30+func (g *Group) AddNode(title, subtitle string) *Node {
31+ node := &Node{Title: title, Subtitle: subtitle}
32+ g.Nodes = append(g.Nodes, node)
33+ return node
34+}
35+
36+// treeItem is a union type for ordering nodes and groups.
37+type treeItem struct {
38+ node *Node
39+ group *Group
40+}
41+
42+// Tree is a renderable tree structure for terminal output.
43+type Tree struct {
44+ items []treeItem
45+}
46+
47+// NewTree creates a new empty tree.
48+func NewTree() *Tree {
49+ return &Tree{}
50+}
51+
52+// AddNode adds a top-level node with a pre-styled title and a plain subtitle.
53+func (t *Tree) AddNode(title, subtitle string) *Node {
54+ node := &Node{Title: title, Subtitle: subtitle}
55+ t.items = append(t.items, treeItem{node: node})
56+ return node
57+}
58+
59+// AddGroup adds a named group header.
60+func (t *Tree) AddGroup(name string) *Group {
61+ group := &Group{Name: name}
62+ t.items = append(t.items, treeItem{group: group})
63+ return group
64+}
65+
66+// Render returns the full styled string output.
67+func (t *Tree) Render() string {
68+ if len(t.items) == 0 {
69+ return ""
70+ }
71+
72+ var b strings.Builder
73+ for _, item := range t.items {
74+ if item.group != nil {
75+ t.renderGroup(&b, item.group)
76+ } else {
77+ t.renderNode(&b, item.node, " ")
78+ }
79+ }
80+ return b.String()
81+}
82+
83+func (t *Tree) renderGroup(b *strings.Builder, group *Group) {
84+ // Group header: ◆ group-name
85+ icon := styles.Theme.Highlight.Render(styles.IconGroup)
86+ name := styles.Theme.Bold.Render(group.Name)
87+ b.WriteString(" " + icon + " " + name + "\n")
88+
89+ if len(group.Nodes) == 0 {
90+ return
91+ }
92+
93+ vertBar := styles.Theme.Muted.Render(styles.IconTreeVert)
94+ b.WriteString(" " + vertBar + "\n")
95+
96+ for _, node := range group.Nodes {
97+ t.renderNode(b, node, " "+vertBar+" ")
98+ }
99+}
100+
101+func (t *Tree) renderNode(b *strings.Builder, node *Node, prefix string) {
102+ // Title line
103+ b.WriteString(prefix + node.Title + "\n")
104+
105+ // Subtitle line (muted, with tree connector aligned under container icon)
106+ if node.Subtitle != "" {
107+ var connectorGlyph string
108+ if len(node.Children) > 0 {
109+ connectorGlyph = styles.IconTreeBranch + styles.IconTreeLine // ├─ more items below
110+ } else {
111+ connectorGlyph = styles.IconTreeLast + styles.IconTreeLine // └─ last item
112+ }
113+ connector := styles.Theme.Muted.Render(connectorGlyph)
114+ subtitle := styles.Theme.Muted.Render(node.Subtitle)
115+ b.WriteString(prefix + " " + connector + " " + subtitle + "\n")
116+ }
117+
118+ // Children
119+ for i, child := range node.Children {
120+ isLast := i == len(node.Children)-1
121+
122+ var branch string
123+ var childPrefix string
124+ if isLast {
125+ branch = styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
126+ childPrefix = " "
127+ } else {
128+ branch = styles.Theme.Muted.Render(styles.IconTreeBranch + styles.IconTreeLine)
129+ childPrefix = styles.Theme.Muted.Render(styles.IconTreeVert) + " "
130+ }
131+
132+ // Child title
133+ b.WriteString(prefix + " " + branch + " " + child.Title + "\n")
134+
135+ // Child subtitle (with tree connector under child name)
136+ if child.Subtitle != "" {
137+ connector := styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
138+ subtitle := styles.Theme.Muted.Render(child.Subtitle)
139+ b.WriteString(prefix + " " + childPrefix + " " + connector + " " + subtitle + "\n")
140+ }
141+ }
142+}
@@ -0,0 +1,142 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
7+)
8+
9+// Node represents a tree node with a title, optional subtitle, and children.
10+type Node struct {
11+ Title string
12+ Subtitle string
13+ Children []*Node
14+}
15+
16+// AddChild adds a child node under this node.
17+func (n *Node) AddChild(title, subtitle string) *Node {
18+ child := &Node{Title: title, Subtitle: subtitle}
19+ n.Children = append(n.Children, child)
20+ return child
21+}
22+
23+// Group represents a named group of nodes rendered with a vertical bar connector.
24+type Group struct {
25+ Name string
26+ Nodes []*Node
27+}
28+
29+// AddNode adds a node inside this group.
30+func (g *Group) AddNode(title, subtitle string) *Node {
31+ node := &Node{Title: title, Subtitle: subtitle}
32+ g.Nodes = append(g.Nodes, node)
33+ return node
34+}
35+
36+// treeItem is a union type for ordering nodes and groups.
37+type treeItem struct {
38+ node *Node
39+ group *Group
40+}
41+
42+// Tree is a renderable tree structure for terminal output.
43+type Tree struct {
44+ items []treeItem
45+}
46+
47+// NewTree creates a new empty tree.
48+func NewTree() *Tree {
49+ return &Tree{}
50+}
51+
52+// AddNode adds a top-level node with a pre-styled title and a plain subtitle.
53+func (t *Tree) AddNode(title, subtitle string) *Node {
54+ node := &Node{Title: title, Subtitle: subtitle}
55+ t.items = append(t.items, treeItem{node: node})
56+ return node
57+}
58+
59+// AddGroup adds a named group header.
60+func (t *Tree) AddGroup(name string) *Group {
61+ group := &Group{Name: name}
62+ t.items = append(t.items, treeItem{group: group})
63+ return group
64+}
65+
66+// Render returns the full styled string output.
67+func (t *Tree) Render() string {
68+ if len(t.items) == 0 {
69+ return ""
70+ }
71+
72+ var b strings.Builder
73+ for _, item := range t.items {
74+ if item.group != nil {
75+ t.renderGroup(&b, item.group)
76+ } else {
77+ t.renderNode(&b, item.node, " ")
78+ }
79+ }
80+ return b.String()
81+}
82+
83+func (t *Tree) renderGroup(b *strings.Builder, group *Group) {
84+ // Group header: ◆ group-name
85+ icon := styles.Theme.Highlight.Render(styles.IconGroup)
86+ name := styles.Theme.Bold.Render(group.Name)
87+ b.WriteString(" " + icon + " " + name + "\n")
88+
89+ if len(group.Nodes) == 0 {
90+ return
91+ }
92+
93+ vertBar := styles.Theme.Muted.Render(styles.IconTreeVert)
94+ b.WriteString(" " + vertBar + "\n")
95+
96+ for _, node := range group.Nodes {
97+ t.renderNode(b, node, " "+vertBar+" ")
98+ }
99+}
100+
101+func (t *Tree) renderNode(b *strings.Builder, node *Node, prefix string) {
102+ // Title line
103+ b.WriteString(prefix + node.Title + "\n")
104+
105+ // Subtitle line (muted, with tree connector aligned under container icon)
106+ if node.Subtitle != "" {
107+ var connectorGlyph string
108+ if len(node.Children) > 0 {
109+ connectorGlyph = styles.IconTreeBranch + styles.IconTreeLine // ├─ more items below
110+ } else {
111+ connectorGlyph = styles.IconTreeLast + styles.IconTreeLine // └─ last item
112+ }
113+ connector := styles.Theme.Muted.Render(connectorGlyph)
114+ subtitle := styles.Theme.Muted.Render(node.Subtitle)
115+ b.WriteString(prefix + " " + connector + " " + subtitle + "\n")
116+ }
117+
118+ // Children
119+ for i, child := range node.Children {
120+ isLast := i == len(node.Children)-1
121+
122+ var branch string
123+ var childPrefix string
124+ if isLast {
125+ branch = styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
126+ childPrefix = " "
127+ } else {
128+ branch = styles.Theme.Muted.Render(styles.IconTreeBranch + styles.IconTreeLine)
129+ childPrefix = styles.Theme.Muted.Render(styles.IconTreeVert) + " "
130+ }
131+
132+ // Child title
133+ b.WriteString(prefix + " " + branch + " " + child.Title + "\n")
134+
135+ // Child subtitle (with tree connector under child name)
136+ if child.Subtitle != "" {
137+ connector := styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
138+ subtitle := styles.Theme.Muted.Render(child.Subtitle)
139+ b.WriteString(prefix + " " + childPrefix + " " + connector + " " + subtitle + "\n")
140+ }
141+ }
142+}
A internal/adapters/in/cli/ui/components/tree_test.go +122 -0
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// NOTE: stripANSI is already defined in table_test.go (same package).
12+// Use it directly — do NOT redefine it here.
13+
14+func TestTree_SingleNodeNoChildren(t *testing.T) {
15+ tree := NewTree()
16+ tree.AddNode("my-app.dev", "myapp:latest")
17+
18+ rendered := stripANSI(tree.Render())
19+
20+ assert.Contains(t, rendered, "my-app.dev")
21+ assert.Contains(t, rendered, "myapp:latest")
22+}
23+
24+func TestTree_NodeWithOneChild(t *testing.T) {
25+ tree := NewTree()
26+ node := tree.AddNode("my-app.dev", "myapp:latest")
27+ node.AddChild("my-postgres", "postgres:16")
28+
29+ rendered := stripANSI(tree.Render())
30+
31+ assert.Contains(t, rendered, "my-app.dev")
32+ assert.Contains(t, rendered, "myapp:latest")
33+ assert.Contains(t, rendered, "└─")
34+ assert.Contains(t, rendered, "my-postgres")
35+ assert.Contains(t, rendered, "postgres:16")
36+}
37+
38+func TestTree_NodeWithMultipleChildren(t *testing.T) {
39+ tree := NewTree()
40+ node := tree.AddNode("my-app.dev", "myapp:latest")
41+ node.AddChild("my-postgres", "postgres:16")
42+ node.AddChild("my-redis", "redis:7")
43+
44+ rendered := stripANSI(tree.Render())
45+
46+ assert.Contains(t, rendered, "├─")
47+ assert.Contains(t, rendered, "└─")
48+ assert.Contains(t, rendered, "my-postgres")
49+ assert.Contains(t, rendered, "my-redis")
50+}
51+
52+func TestTree_GroupWithNodes(t *testing.T) {
53+ tree := NewTree()
54+ group := tree.AddGroup("shared-services")
55+ group.AddNode("app-a.dev", "app-a:latest")
56+ group.AddNode("app-b.dev", "app-b:latest")
57+
58+ rendered := stripANSI(tree.Render())
59+
60+ assert.Contains(t, rendered, "shared-services")
61+ assert.Contains(t, rendered, "◆")
62+ assert.Contains(t, rendered, "│")
63+ assert.Contains(t, rendered, "app-a.dev")
64+ assert.Contains(t, rendered, "app-b.dev")
65+}
66+
67+func TestTree_MixedNodesAndGroups(t *testing.T) {
68+ tree := NewTree()
69+ tree.AddNode("alpha.dev", "alpha:latest")
70+
71+ group := tree.AddGroup("my-group")
72+ group.AddNode("beta.dev", "beta:latest")
73+ group.AddNode("gamma.dev", "gamma:latest")
74+
75+ tree.AddNode("delta.dev", "delta:latest")
76+
77+ rendered := stripANSI(tree.Render())
78+
79+ assert.Contains(t, rendered, "alpha.dev")
80+ assert.Contains(t, rendered, "my-group")
81+ assert.Contains(t, rendered, "beta.dev")
82+ assert.Contains(t, rendered, "gamma.dev")
83+ assert.Contains(t, rendered, "delta.dev")
84+}
85+
86+func TestTree_EmptyTreeRendersEmpty(t *testing.T) {
87+ tree := NewTree()
88+ rendered := tree.Render()
89+ assert.Empty(t, rendered)
90+}
91+
92+func TestTree_NodeWithChildSubtitleIndented(t *testing.T) {
93+ tree := NewTree()
94+ node := tree.AddNode("my-app.dev", "myapp:latest")
95+ node.AddChild("my-postgres", "postgres:16")
96+
97+ rendered := stripANSI(tree.Render())
98+ lines := strings.Split(rendered, "\n")
99+
100+ var childSubtitleLine string
101+ for _, line := range lines {
102+ if strings.Contains(line, "postgres:16") {
103+ childSubtitleLine = line
104+ break
105+ }
106+ }
107+ require.NotEmpty(t, childSubtitleLine, "child subtitle line should exist")
108+}
109+
110+func TestTree_GroupNodeWithChild(t *testing.T) {
111+ tree := NewTree()
112+ group := tree.AddGroup("shared")
113+ node := group.AddNode("app.dev", "app:latest")
114+ node.AddChild("app-db", "postgres:16")
115+
116+ rendered := stripANSI(tree.Render())
117+
118+ assert.Contains(t, rendered, "shared")
119+ assert.Contains(t, rendered, "app.dev")
120+ assert.Contains(t, rendered, "app-db")
121+ assert.Contains(t, rendered, "postgres:16")
122+}
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// NOTE: stripANSI is already defined in table_test.go (same package).
12+// Use it directly — do NOT redefine it here.
13+
14+func TestTree_SingleNodeNoChildren(t *testing.T) {
15+ tree := NewTree()
16+ tree.AddNode("my-app.dev", "myapp:latest")
17+
18+ rendered := stripANSI(tree.Render())
19+
20+ assert.Contains(t, rendered, "my-app.dev")
21+ assert.Contains(t, rendered, "myapp:latest")
22+}
23+
24+func TestTree_NodeWithOneChild(t *testing.T) {
25+ tree := NewTree()
26+ node := tree.AddNode("my-app.dev", "myapp:latest")
27+ node.AddChild("my-postgres", "postgres:16")
28+
29+ rendered := stripANSI(tree.Render())
30+
31+ assert.Contains(t, rendered, "my-app.dev")
32+ assert.Contains(t, rendered, "myapp:latest")
33+ assert.Contains(t, rendered, "└─")
34+ assert.Contains(t, rendered, "my-postgres")
35+ assert.Contains(t, rendered, "postgres:16")
36+}
37+
38+func TestTree_NodeWithMultipleChildren(t *testing.T) {
39+ tree := NewTree()
40+ node := tree.AddNode("my-app.dev", "myapp:latest")
41+ node.AddChild("my-postgres", "postgres:16")
42+ node.AddChild("my-redis", "redis:7")
43+
44+ rendered := stripANSI(tree.Render())
45+
46+ assert.Contains(t, rendered, "├─")
47+ assert.Contains(t, rendered, "└─")
48+ assert.Contains(t, rendered, "my-postgres")
49+ assert.Contains(t, rendered, "my-redis")
50+}
51+
52+func TestTree_GroupWithNodes(t *testing.T) {
53+ tree := NewTree()
54+ group := tree.AddGroup("shared-services")
55+ group.AddNode("app-a.dev", "app-a:latest")
56+ group.AddNode("app-b.dev", "app-b:latest")
57+
58+ rendered := stripANSI(tree.Render())
59+
60+ assert.Contains(t, rendered, "shared-services")
61+ assert.Contains(t, rendered, "◆")
62+ assert.Contains(t, rendered, "│")
63+ assert.Contains(t, rendered, "app-a.dev")
64+ assert.Contains(t, rendered, "app-b.dev")
65+}
66+
67+func TestTree_MixedNodesAndGroups(t *testing.T) {
68+ tree := NewTree()
69+ tree.AddNode("alpha.dev", "alpha:latest")
70+
71+ group := tree.AddGroup("my-group")
72+ group.AddNode("beta.dev", "beta:latest")
73+ group.AddNode("gamma.dev", "gamma:latest")
74+
75+ tree.AddNode("delta.dev", "delta:latest")
76+
77+ rendered := stripANSI(tree.Render())
78+
79+ assert.Contains(t, rendered, "alpha.dev")
80+ assert.Contains(t, rendered, "my-group")
81+ assert.Contains(t, rendered, "beta.dev")
82+ assert.Contains(t, rendered, "gamma.dev")
83+ assert.Contains(t, rendered, "delta.dev")
84+}
85+
86+func TestTree_EmptyTreeRendersEmpty(t *testing.T) {
87+ tree := NewTree()
88+ rendered := tree.Render()
89+ assert.Empty(t, rendered)
90+}
91+
92+func TestTree_NodeWithChildSubtitleIndented(t *testing.T) {
93+ tree := NewTree()
94+ node := tree.AddNode("my-app.dev", "myapp:latest")
95+ node.AddChild("my-postgres", "postgres:16")
96+
97+ rendered := stripANSI(tree.Render())
98+ lines := strings.Split(rendered, "\n")
99+
100+ var childSubtitleLine string
101+ for _, line := range lines {
102+ if strings.Contains(line, "postgres:16") {
103+ childSubtitleLine = line
104+ break
105+ }
106+ }
107+ require.NotEmpty(t, childSubtitleLine, "child subtitle line should exist")
108+}
109+
110+func TestTree_GroupNodeWithChild(t *testing.T) {
111+ tree := NewTree()
112+ group := tree.AddGroup("shared")
113+ node := group.AddNode("app.dev", "app:latest")
114+ node.AddChild("app-db", "postgres:16")
115+
116+ rendered := stripANSI(tree.Render())
117+
118+ assert.Contains(t, rendered, "shared")
119+ assert.Contains(t, rendered, "app.dev")
120+ assert.Contains(t, rendered, "app-db")
121+ assert.Contains(t, rendered, "postgres:16")
122+}
M internal/adapters/in/cli/ui/styles/icons.go +5 -0
@@ -31,6 +31,11 @@ const (
3131 IconImage = "\uf187" // nf-fa-archive
3232 IconDocker = "\ue7b0" // nf-dev-docker
3333
34+ // Tree view status icons
35+ IconContainerStatus = "\U000f01a7" // nf-md-cube_outline — container health in tree view
36+ IconHTTPStatus = "\U000f059f" // nf-md-web — HTTP probe status in tree view
37+ IconGroup = "\u25c6" // ◆ — network group header
38+
3439 // Actions
3540 IconAdd = "\uf067" // nf-fa-plus
3641 IconRemove = "\uf068" // nf-fa-minus
@@ -31,6 +31,11 @@ const (
31 IconImage = "\uf187" // nf-fa-archive31 IconImage = "\uf187" // nf-fa-archive
32 IconDocker = "\ue7b0" // nf-dev-docker32 IconDocker = "\ue7b0" // nf-dev-docker
33 33
34+ // Tree view status icons
35+ IconContainerStatus = "\U000f01a7" // nf-md-cube_outline — container health in tree view
36+ IconHTTPStatus = "\U000f059f" // nf-md-web — HTTP probe status in tree view
37+ IconGroup = "\u25c6" // ◆ — network group header
38+
34 // Actions39 // Actions
35 IconAdd = "\uf067" // nf-fa-plus40 IconAdd = "\uf067" // nf-fa-plus
36 IconRemove = "\uf068" // nf-fa-minus41 IconRemove = "\uf068" // nf-fa-minus

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close