@@ -13,22 +13,32 @@ import (
1313 "github.com/agent462/herd/internal/grouper"
1414)
1515
16- // outputPane wraps a bubbles/viewport for displaying grouped command results.
16+ // tabBarHeight is the number of rows consumed by the tab bar.
17+ const tabBarHeight = 2 // 1 row for tabs + 1 row for bottom border
18+
19+ // outputPane wraps a bubbles/viewport for displaying grouped command results,
20+ // with a tab bar for switching between the diff view and per-host output.
1721type outputPane struct {
18- viewport viewport.Model
19- width int
20- height int
21- expandedHost string // when non-empty, show only this host's output
22+ viewport viewport.Model
23+ tabBar tabBar
24+ width int
25+ height int
26+
27+ // Cached data for re-rendering when tabs switch.
28+ lastGrouped * grouper.GroupedResults
29+ lastResults []* executor.HostResult
30+ allHosts []string
2231}
2332
2433func newOutputPane (width , height int ) outputPane {
2534 contentWidth := width - 2 // account for pane border
2635 vp := viewport .New (
2736 viewport .WithWidth (contentWidth ),
28- viewport .WithHeight (height - 2 ), // account for border
37+ viewport .WithHeight (height - 2 - tabBarHeight ), // border + tab bar
2938 )
3039 return outputPane {
3140 viewport : vp ,
41+ tabBar : newTabBar (contentWidth ),
3242 width : contentWidth ,
3343 height : height ,
3444 }
@@ -41,13 +51,14 @@ func (o *outputPane) Update(msg tea.Msg) tea.Cmd {
4151}
4252
4353func (o * outputPane ) View () string {
44- // Hard-clip the viewport output to prevent any line from exceeding the
45- // content width. The viewport pads lines but does not truncate, so this
46- // catches edge cases where styled/ANSI content is wider than expected.
54+ bar := o .tabBar .View ()
55+ var content string
4756 if o .width > 0 {
48- return lipgloss .NewStyle ().MaxWidth (o .width ).Render (o .viewport .View ())
57+ content = lipgloss .NewStyle ().MaxWidth (o .width ).Render (o .viewport .View ())
58+ } else {
59+ content = o .viewport .View ()
4960 }
50- return o . viewport . View ( )
61+ return lipgloss . JoinVertical ( lipgloss . Left , bar , content )
5162}
5263
5364// setContent truncates each line to the viewport width (ANSI-aware) before
@@ -68,37 +79,105 @@ func (o *outputPane) Resize(width, height int) {
6879 o .width = width - 2 // content width inside pane border
6980 o .height = height
7081 o .viewport .SetWidth (o .width )
71- o .viewport .SetHeight (height - 2 )
82+ o .viewport .SetHeight (height - 2 - tabBarHeight )
83+ o .tabBar .Resize (o .width )
7284}
7385
86+ // SetGroupedResults updates the output pane with new execution results.
87+ // Rebuilds the tab bar and re-renders the active tab's content.
7488func (o * outputPane ) SetGroupedResults (grouped * grouper.GroupedResults , results []* executor.HostResult ) {
89+ o .lastGrouped = grouped
90+ o .lastResults = results
91+
7592 if grouped == nil {
7693 o .setContent ("No results yet. Type a command below." )
7794 return
7895 }
7996
80- if o .expandedHost != "" {
81- o .renderHostOutput (o .expandedHost , grouped , results )
82- return
97+ // Collect all hosts in order from results.
98+ hosts := make ([]string , 0 , len (results ))
99+ for _ , r := range results {
100+ hosts = append (hosts , r .Host )
101+ }
102+ o .allHosts = hosts
103+ o .tabBar .SetTabs (hosts )
104+
105+ o .renderActiveTab ()
106+ }
107+
108+ // renderActiveTab dispatches to the correct renderer based on the active tab.
109+ func (o * outputPane ) renderActiveTab () {
110+ id := o .tabBar .ActiveID ()
111+ if id == "diff" {
112+ if o .lastGrouped != nil {
113+ o .renderGrouped (o .lastGrouped )
114+ }
115+ } else {
116+ o .renderHostOutput (id , o .lastGrouped , o .lastResults )
83117 }
118+ }
84119
85- o .renderGrouped (grouped )
120+ // NextTab switches to the next tab and re-renders.
121+ func (o * outputPane ) NextTab () {
122+ o .tabBar .Next ()
123+ o .renderActiveTab ()
86124}
87125
126+ // PrevTab switches to the previous tab and re-renders.
127+ func (o * outputPane ) PrevTab () {
128+ o .tabBar .Prev ()
129+ o .renderActiveTab ()
130+ }
131+
132+ // SetTabIndex jumps to a tab by index and re-renders.
133+ func (o * outputPane ) SetTabIndex (index int ) {
134+ o .tabBar .SetActive (index )
135+ o .renderActiveTab ()
136+ }
137+
138+ // ActivateHostTab switches to a specific host's tab.
139+ // Returns true if the host was found in the tab list.
140+ func (o * outputPane ) ActivateHostTab (hostname string ) bool {
141+ if o .tabBar .SetActiveByID (hostname ) {
142+ o .renderActiveTab ()
143+ return true
144+ }
145+ return false
146+ }
147+
148+ // ActivateDiffTab switches back to the diff output tab.
149+ func (o * outputPane ) ActivateDiffTab () {
150+ o .tabBar .SetActive (0 )
151+ o .renderActiveTab ()
152+ }
153+
154+ // ExpandHost is a backwards-compatible wrapper that activates the host's tab.
88155func (o * outputPane ) ExpandHost (name string , grouped * grouper.GroupedResults , results []* executor.HostResult ) {
89- o .expandedHost = name
90- o .renderHostOutput (name , grouped , results )
156+ o .lastGrouped = grouped
157+ o .lastResults = results
158+ // Ensure tabs are populated.
159+ if len (o .tabBar .tabs ) <= 1 {
160+ hosts := make ([]string , 0 , len (results ))
161+ for _ , r := range results {
162+ hosts = append (hosts , r .Host )
163+ }
164+ o .allHosts = hosts
165+ o .tabBar .SetTabs (hosts )
166+ }
167+ o .tabBar .SetActiveByID (name )
168+ o .renderActiveTab ()
91169}
92170
171+ // CollapseHost is a backwards-compatible wrapper that returns to the diff tab.
93172func (o * outputPane ) CollapseHost (grouped * grouper.GroupedResults , results []* executor.HostResult ) {
94- o .expandedHost = ""
95- if grouped != nil {
96- o .renderGrouped (grouped )
97- }
173+ o .lastGrouped = grouped
174+ o .lastResults = results
175+ o .ActivateDiffTab ()
98176}
99177
178+ // IsExpanded returns true if viewing a specific host (not the diff tab).
100179func (o * outputPane ) IsExpanded () bool {
101- return o .expandedHost != ""
180+ return o .tabBar . ActiveID () != "diff "
102181}
103182
104183func (o * outputPane ) renderGrouped (grouped * grouper.GroupedResults ) {
0 commit comments