11package ui
22
33import (
4- "github.com/charmbracelet/bubbles/table"
54 tea "github.com/charmbracelet/bubbletea"
65 "github.com/charmbracelet/lipgloss"
6+ lgtable "github.com/charmbracelet/lipgloss/table"
77
88 inpb "github.com/buildbuddy-io/buildbuddy/proto/invocation"
99)
1010
1111type listModel struct {
1212 api * apiClient
1313 filters filters
14- table table.Model
1514 invocations []* inpb.Invocation
1615 loading bool
1716 err error
1817 width int
1918 height int
19+ cursor int
20+ offset int
2021 selectedID string
2122 switchOrg bool
2223 hasOrgPicker bool
2324}
2425
25- func newListModel (api * apiClient , f filters , hasOrgPicker bool ) listModel {
26- columns := []table.Column {
27- {Title : "Status" , Width : 14 },
28- {Title : "User" , Width : 12 },
29- {Title : "Command" , Width : 8 },
30- {Title : "Pattern" , Width : 30 },
31- {Title : "Branch" , Width : 16 },
32- {Title : "Duration" , Width : 10 },
33- {Title : "When" , Width : 10 },
34- }
35-
36- t := table .New (
37- table .WithColumns (columns ),
38- table .WithFocused (true ),
39- table .WithHeight (20 ),
40- )
41-
42- s := table .DefaultStyles ()
43- s .Header = s .Header .
44- BorderStyle (lipgloss .NormalBorder ()).
45- BorderForeground (colorDim ).
46- BorderBottom (true ).
47- Bold (true )
48- s .Selected = s .Selected .
49- Foreground (lipgloss .Color ("#ffffff" )).
50- Background (lipgloss .Color ("#5533cc" )).
51- Bold (false )
52- t .SetStyles (s )
26+ type listColumnWidths struct {
27+ status int
28+ user int
29+ command int
30+ pattern int
31+ branch int
32+ duration int
33+ when int
34+ }
5335
36+ func newListModel (api * apiClient , f filters , hasOrgPicker bool ) listModel {
5437 return listModel {
5538 api : api ,
5639 filters : f ,
57- table : t ,
5840 loading : true ,
5941 hasOrgPicker : hasOrgPicker ,
6042 }
@@ -71,12 +53,15 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
7153 switch msg := msg .(type ) {
7254 case tea.KeyMsg :
7355 switch msg .String () {
56+ case "up" , "k" :
57+ m .moveCursor (- 1 )
58+ return m , nil
59+ case "down" , "j" :
60+ m .moveCursor (1 )
61+ return m , nil
7462 case "enter" :
75- if sel := m .table .SelectedRow (); len (sel ) > 0 {
76- idx := m .table .Cursor ()
77- if idx >= 0 && idx < len (m .invocations ) {
78- m .selectedID = m .invocations [idx ].GetInvocationId ()
79- }
63+ if m .cursor >= 0 && m .cursor < len (m .invocations ) {
64+ m .selectedID = m .invocations [m .cursor ].GetInvocationId ()
8065 }
8166 return m , nil
8267 case "r" :
@@ -96,12 +81,8 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
9681 return m , nil
9782 }
9883 m .err = nil
99- cursor := m .table .Cursor ()
10084 m .invocations = msg .invocations
101- m .table .SetRows (m .buildRows ())
102- if cursor < len (m .invocations ) {
103- m .table .SetCursor (cursor )
104- }
85+ m .clampCursorAndOffset ()
10586 return m , nil
10687
10788 case tickMsg :
@@ -113,23 +94,96 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
11394 case tea.WindowSizeMsg :
11495 m .width = msg .Width
11596 m .height = msg .Height
116- m .table .SetHeight (msg .Height - 5 )
117- m .updateColumnWidths ()
97+ m .clampCursorAndOffset ()
11898 return m , nil
11999 }
120100
121- var cmd tea.Cmd
122- m .table , cmd = m .table .Update (msg )
123- return m , cmd
101+ return m , nil
124102}
125103
126- func (m * listModel ) updateColumnWidths ( ) {
127- if m . width < 60 {
104+ func (m * listModel ) moveCursor ( delta int ) {
105+ if len ( m . invocations ) == 0 {
128106 return
129107 }
130- // Fixed columns: Status(14) + User(12) + Command(8) + Duration(10) + When(10) = 54
131- // Plus separators ~6
132- fixed := 60
108+ m .cursor += delta
109+ m .clampCursorAndOffset ()
110+ }
111+
112+ func (m * listModel ) clampCursorAndOffset () {
113+ if len (m .invocations ) == 0 {
114+ m .cursor = 0
115+ m .offset = 0
116+ return
117+ }
118+ if m .cursor < 0 {
119+ m .cursor = 0
120+ }
121+ if m .cursor >= len (m .invocations ) {
122+ m .cursor = len (m .invocations ) - 1
123+ }
124+
125+ visibleRows := m .visibleRows ()
126+ maxOffset := len (m .invocations ) - visibleRows
127+ if maxOffset < 0 {
128+ maxOffset = 0
129+ }
130+ if m .offset < 0 {
131+ m .offset = 0
132+ }
133+ if m .offset > maxOffset {
134+ m .offset = maxOffset
135+ }
136+ if m .cursor < m .offset {
137+ m .offset = m .cursor
138+ }
139+ if m .cursor >= m .offset + visibleRows {
140+ m .offset = m .cursor - visibleRows + 1
141+ }
142+ }
143+
144+ func (m listModel ) visibleRows () int {
145+ rows := m .tableAreaHeight () - 2 // account for header row + header separator
146+ if rows < 1 {
147+ return 1
148+ }
149+ return rows
150+ }
151+
152+ func (m listModel ) showStatusLine () bool {
153+ return (m .loading && len (m .invocations ) == 0 ) || m .err != nil
154+ }
155+
156+ func (m listModel ) tableAreaHeight () int {
157+ // title/filter line + help line are always visible.
158+ h := m .height - 2
159+ // Loading / error status line is conditionally visible.
160+ if m .showStatusLine () {
161+ h --
162+ }
163+ if h < 3 {
164+ return 3
165+ }
166+ return h
167+ }
168+
169+ func (m listModel ) columnWidths () listColumnWidths {
170+ widths := listColumnWidths {
171+ status : 14 ,
172+ user : 12 ,
173+ command : 14 ,
174+ pattern : 30 ,
175+ branch : 16 ,
176+ duration : 10 ,
177+ when : 12 ,
178+ }
179+
180+ if m .width <= 0 {
181+ return widths
182+ }
183+
184+ // Non-resizable content widths: Status(14) + User(12) + Command(14) + Duration(10) + When(12) = 62
185+ // Pattern and Branch consume the remaining width.
186+ fixed := 62
133187 remaining := m .width - fixed
134188 patternW := remaining * 2 / 3
135189 branchW := remaining - patternW
@@ -139,35 +193,112 @@ func (m *listModel) updateColumnWidths() {
139193 if branchW < 8 {
140194 branchW = 8
141195 }
196+ widths .pattern = patternW
197+ widths .branch = branchW
142198
143- columns := []table.Column {
144- {Title : "Status" , Width : 14 },
145- {Title : "User" , Width : 12 },
146- {Title : "Command" , Width : 8 },
147- {Title : "Pattern" , Width : patternW },
148- {Title : "Branch" , Width : branchW },
149- {Title : "Duration" , Width : 10 },
150- {Title : "When" , Width : 10 },
151- }
152- m .table .SetColumns (columns )
199+ return widths
153200}
154201
155- func (m listModel ) buildRows () []table. Row {
156- rows := make ([]table. Row , len (m .invocations ))
202+ func (m listModel ) buildRows (widths listColumnWidths ) [][] string {
203+ rows := make ([][] string , len (m .invocations ))
157204 for i , inv := range m .invocations {
158- rows [i ] = table. Row {
205+ rows [i ] = [] string {
159206 formatStatus (inv ),
160- truncate (inv .GetUser (), 12 ),
161- inv .GetCommand (),
162- truncate (formatPattern (inv .GetPattern ()), 30 ),
163- truncate (inv .GetBranchName (), 16 ),
207+ truncate (inv .GetUser (), widths . user ),
208+ truncate ( inv .GetCommand (), widths . command ),
209+ truncate (formatPattern (inv .GetPattern ()), widths . pattern ),
210+ truncate (inv .GetBranchName (), widths . branch ),
164211 formatDuration (inv .GetDurationUsec ()),
165212 formatTimeAgo (inv .GetUpdatedAtUsec ()),
166213 }
167214 }
168215 return rows
169216}
170217
218+ func (m listModel ) renderTable () string {
219+ widths := m .columnWidths ()
220+ allRows := m .buildRows (widths )
221+ tableHeight := m .tableAreaHeight ()
222+ start := m .offset
223+ if start < 0 {
224+ start = 0
225+ }
226+ if start > len (allRows ) {
227+ start = len (allRows )
228+ }
229+ end := start + (tableHeight - 2 )
230+ if end < start {
231+ end = start
232+ }
233+ if end > len (allRows ) {
234+ end = len (allRows )
235+ }
236+ visibleRows := allRows [start :end ]
237+
238+ t := lgtable .New ().
239+ Headers ("Status" , "User" , "Command" , "Pattern" , "Branch" , "Duration" , "When" ).
240+ Rows (visibleRows ... ).
241+ Border (lipgloss .NormalBorder ()).
242+ BorderTop (false ).
243+ BorderBottom (false ).
244+ BorderLeft (false ).
245+ BorderRight (false ).
246+ BorderColumn (false ).
247+ BorderRow (false ).
248+ BorderHeader (true ).
249+ BorderStyle (lipgloss .NewStyle ().Foreground (colorDim )).
250+ Wrap (false ).
251+ StyleFunc (func (row , col int ) lipgloss.Style {
252+ style := lipgloss .NewStyle ().
253+ Width (columnWidth (widths , col )).
254+ MaxWidth (columnWidth (widths , col )).
255+ Padding (0 , 1 )
256+
257+ if row == lgtable .HeaderRow {
258+ return style .Bold (true )
259+ }
260+
261+ absoluteRow := start + row
262+ if absoluteRow == m .cursor {
263+ style = style .Background (lipgloss .Color ("#5533cc" )).Foreground (colorWhite )
264+ }
265+
266+ if row >= 0 && row < len (visibleRows ) && col == 0 {
267+ style = style .Inherit (statusTextStyle (visibleRows [row ][0 ]))
268+ }
269+
270+ return style
271+ })
272+
273+ table := t .String ()
274+ frame := lipgloss .NewStyle ().Height (tableHeight )
275+ if m .width > 0 {
276+ frame = frame .Width (m .width )
277+ }
278+ return frame .Render (table )
279+ }
280+
281+ func columnWidth (widths listColumnWidths , col int ) int {
282+ switch col {
283+ case 0 :
284+ return widths .status
285+ case 1 :
286+ return widths .user
287+ case 2 :
288+ return widths .command
289+ case 3 :
290+ return widths .pattern
291+ case 4 :
292+ return widths .branch
293+ case 5 :
294+ return widths .duration
295+ case 6 :
296+ return widths .when
297+ default :
298+ return 0
299+ }
300+ }
301+
171302func (m listModel ) View () string {
172303 title := titleStyle .Render ("BuildBuddy Builds" )
173304 filterStr := formatActiveFilters (m .filters )
@@ -186,5 +317,5 @@ func (m listModel) View() string {
186317 helpText += " • q: quit"
187318 help := helpStyle .Render (helpText )
188319
189- return title + filterStr + status + "\n " + m .table . View () + "\n " + help
320+ return title + filterStr + status + "\n " + m .renderTable () + "\n " + help
190321}
0 commit comments