Skip to content

Commit 3c74b02

Browse files
authored
[CAP-7450] Implement paginated infinite scroll on CLI logs viewer (#243)
* Move log limit constant into shared package Might be overkill, but this will protect us against documentation inconsistency if we decide to change this value in the backend. * [CAP-7450] Implement paginated infinite scroll on CLI logs viewer Implements automatic pagination for the logs viewer that fetches additional logs when users scroll to the boundaries of the fetched log data. The initial log request continues to respect the `limit` flag, but subsequent requests (triggered by scrolling) are auto-paginated with a 1000-log limit. Subsequent requests are also 'silent' and do not display the loading spinner. Logs always display oldest to newest regardless of "direction", consistent with the existing implementation. ("Direction" only affects which end of the fetched set appears first in the viewport.) * [CAP-7450] Support scrolling up immediately after initial log load Existing logic only fetches logs when the viewport has shifted to a boundary of the fetched set. This is valuable because it prevents us from fetching older logs until the user requests it; however it also prevents the user from immediately scrolling up after the initial log fetch because the viewport has not shifted. This commit manually detects a scroll up key binding when the viewport hasn't changed. The corresponding check will never come into play in the "down" direction unless the user specifies `--limit=1`, at which point they clearly don't want more logs anyway so we shan't provide them. * Actually allow pagination requests to fetch 1000 logs at a time
1 parent 092c6a9 commit 3c74b02

File tree

6 files changed

+414
-9
lines changed

6 files changed

+414
-9
lines changed

cmd/logs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
lclient "github.com/render-oss/cli/pkg/client/logs"
1313
"github.com/render-oss/cli/pkg/command"
14+
"github.com/render-oss/cli/pkg/logs"
1415
"github.com/render-oss/cli/pkg/tui/flows"
1516
"github.com/render-oss/cli/pkg/tui/views"
1617
)
@@ -74,7 +75,7 @@ In interactive mode you can update the filters and view logs in real time.`,
7475
logCmd.Flags().StringSlice("status-code", []string{}, "A list of comma separated status codes to query")
7576
logCmd.Flags().Var(methodTypeFlag, "method", "A list of comma separated HTTP methods to query")
7677
logCmd.Flags().StringSlice("path", []string{}, "A list of comma separated paths to query")
77-
logCmd.Flags().Int("limit", 100, "The maximum number of logs to return")
78+
logCmd.Flags().Int("limit", logs.DefaultLogLimit, "The maximum number of logs to return")
7879
logCmd.Flags().Var(directionFlag, "direction", "The direction to query the logs. Can be 'forward' or 'backward'")
7980
logCmd.Flags().Bool("tail", false, "Stream new logs")
8081
logCmd.Flags().StringSlice("task-id", []string{}, "A list of comma separated task IDs to query")

pkg/logs/constants.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package logs
2+
3+
const (
4+
// Default maximum number of logs to return in an initial request
5+
DefaultLogLimit = 100
6+
// Default maximum number of logs to return in pagination requests
7+
PaginationLogLimit = 1000
8+
)

pkg/tui/logs.go

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,20 @@ func NewLogModel(loadFunc TypedCmd[*LogResult]) *LogModel {
2828
scrollBar: NewScrollBarModel(1, 0),
2929
viewport: viewport.New(0, 0),
3030
state: logStateLoading,
31+
direction: lclient.Backward, // default direction
3132
}
3233
}
3334

35+
// Sets the function to call when more logs need to be loaded
36+
func (m *LogModel) SetLoadMoreFunc(f func(startTime, endTime *time.Time) tea.Cmd) {
37+
m.loadMoreFunc = f
38+
}
39+
40+
// Sets the log query direction for pagination logic
41+
func (m *LogModel) SetDirection(direction lclient.LogDirection) {
42+
m.direction = direction
43+
}
44+
3445
type logState string
3546

3647
const (
@@ -39,18 +50,28 @@ const (
3950
)
4051

4152
type LogModel struct {
42-
loadFunc TypedCmd[*LogResult]
43-
content []string
44-
state logState
45-
viewport viewport.Model
46-
scrollBar *ScrollBarModel
47-
help help.Model
53+
loadFunc TypedCmd[*LogResult]
54+
loadMoreFunc func(startTime, endTime *time.Time) tea.Cmd
55+
content []string
56+
state logState
57+
viewport viewport.Model
58+
scrollBar *ScrollBarModel
59+
help help.Model
4860

4961
windowWidth int
5062
windowHeight int
5163
top int
5264

5365
logChan <-chan *lclient.Log
66+
67+
// Pagination state
68+
hasMore bool
69+
nextStartTime *time.Time
70+
nextEndTime *time.Time
71+
direction lclient.LogDirection
72+
isLoadingMore bool
73+
initialLoadDone bool // Track if initial load is complete to prevent immediate auto-fetch
74+
lastYOffset int // Track last Y offset to detect actual scroll changes
5475
}
5576

5677
type appendLogsMsg struct {
@@ -97,16 +118,87 @@ func (m *LogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97118
cmds []tea.Cmd
98119
)
99120

121+
// Check if user is trying to scroll up while at top (before viewport
122+
// update); this is always the case after initial load
123+
wasAtTop := m.viewport.AtTop()
124+
userTriedToScrollUp := false
125+
126+
// Detect key presses that would scroll up
127+
if keyMsg, ok := msg.(tea.KeyMsg); ok {
128+
if key.Matches(keyMsg, m.viewport.KeyMap.Up) ||
129+
key.Matches(keyMsg, m.viewport.KeyMap.HalfPageUp) ||
130+
key.Matches(keyMsg, m.viewport.KeyMap.PageUp) {
131+
if wasAtTop && m.state == logStateLoaded {
132+
userTriedToScrollUp = true
133+
}
134+
}
135+
}
136+
100137
// Handle keyboard and mouse events in the viewport
101138
m.viewport, cmd = m.viewport.Update(msg)
102139
cmds = append(cmds, cmd)
103140

141+
// Check if we should load more logs based on scroll position
142+
// - when scrolling up, load older logs (prepend above)
143+
// - when scrolling down, load newer logs (append below)
144+
if m.state == logStateLoaded && m.hasMore && !m.isLoadingMore && m.loadMoreFunc != nil && m.logChan == nil {
145+
// Mark initial load as done on first update after content is loaded
146+
if !m.initialLoadDone && len(m.content) > 0 {
147+
m.initialLoadDone = true
148+
m.lastYOffset = m.viewport.YOffset
149+
}
150+
151+
if m.initialLoadDone {
152+
shouldLoadMore := false
153+
154+
if m.direction == lclient.Backward {
155+
// Backward direction queries most recent logs first
156+
shouldLoadMore = (m.viewport.AtTop() && m.viewport.YOffset != m.lastYOffset) || userTriedToScrollUp
157+
} else {
158+
// Forward direction queries oldest logs first
159+
shouldLoadMore = m.viewport.AtBottom() && m.viewport.YOffset != m.lastYOffset
160+
}
161+
162+
if shouldLoadMore {
163+
m.isLoadingMore = true
164+
cmds = append(cmds, m.loadMoreFunc(m.nextStartTime, m.nextEndTime))
165+
}
166+
167+
m.lastYOffset = m.viewport.YOffset
168+
}
169+
}
170+
104171
switch msg := msg.(type) {
105172
case LoadDataMsg[*LogResult]:
106173
if msg.Data.Logs != nil {
107-
m.content = formatLogs(msg.Data.Logs.Logs)
174+
// If loading more logs, append or prepend based on direction
175+
if m.isLoadingMore {
176+
newContent := formatLogs(msg.Data.Logs.Logs)
177+
178+
if m.direction == lclient.Backward {
179+
// Backward direction: paginated scroll prepends older logs
180+
m.content = append(newContent, m.content...)
181+
// Adjust viewport to maintain user's scroll position
182+
newYOffset := m.viewport.YOffset + len(newContent)
183+
m.viewport.SetYOffset(newYOffset)
184+
} else {
185+
// Forward direction: paginated scroll appends newer logs
186+
m.content = append(m.content, newContent...)
187+
}
188+
m.isLoadingMore = false
189+
} else {
190+
// Initial load
191+
m.content = formatLogs(msg.Data.Logs.Logs)
192+
// Mark initial load as complete (but only after first viewport update)
193+
}
194+
195+
// Update pagination state
196+
m.hasMore = msg.Data.Logs.HasMore
197+
m.nextStartTime = &msg.Data.Logs.NextStartTime
198+
m.nextEndTime = &msg.Data.Logs.NextEndTime
108199
} else {
109200
m.content = []string{}
201+
m.hasMore = false
110202
}
111203

112204
m.logChan = msg.Data.LogChannel

0 commit comments

Comments
 (0)