1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
|
package dashboard
import (
"sort"
"ior/internal/statsengine"
common "ior/internal/tui/common"
"ior/internal/tui/eventstream"
flamegraphtui "ior/internal/tui/flamegraph"
"ior/internal/tui/messages"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// tabRenderFn is a function that renders a tab's content area given the
// current model state and viewport dimensions. It returns the rendered string.
type tabRenderFn func(m *Model, snap *statsengine.Snapshot, stream *eventstream.Model, flame *flamegraphtui.Model, width, height int) string
// tabScrollFn handles scroll key presses for a specific tab. Returns whether
// the key was handled and an optional tea.Cmd. It is called only when bubbles
// are not active (bubble scroll is handled before tab dispatch).
type tabScrollFn func(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd)
// tabDescriptor captures all per-tab metadata and behaviour so that adding a
// new tab only requires registering a new entry — no switch statements need to
// be modified.
type tabDescriptor struct {
// Name is the full display label shown in the tab bar (e.g. "Overview").
Name string
// ShortName is the abbreviated label used when the tab bar is narrow.
ShortName string
// Position controls the left-to-right order of tabs in the tab bar.
// Lower values appear first. The existing tabs use positions 10–70 in
// steps of 10 so new tabs can be inserted without renumbering.
Position int
// AllowedVizModes lists the visualization modes available for this tab.
// Tabs that only support the plain table view contain a single entry.
AllowedVizModes []tabVizMode
// InitCmd is an optional extra Bubble Tea command to start alongside the
// global refresh tick when this tab is the active tab on Init. Tabs that
// need their own high-frequency tick (stream, flame) set this; others leave
// it nil. The model is passed so the closure can use the configured
// fastRefreshEvery interval rather than a hardcoded constant.
InitCmd func(*Model) tea.Cmd
// Render draws the tab body. Nil means the tab has no registered renderer
// (used for tabs that handle rendering via other paths).
Render tabRenderFn
// HandleScroll handles direction keys for this tab when bubbles are off.
// Nil means the tab does not process scroll/navigation keys.
HandleScroll tabScrollFn
// ShortcutKey extracts the numeric shortcut key binding for this tab from
// a KeyMap. It is called at runtime against the model's configured key map
// so that custom key maps (e.g. in tests) are respected. Nil means the tab
// has no direct numeric shortcut; it is still reachable via tab/shift+tab.
ShortcutKey func(keys common.KeyMap) key.Binding
}
// tabDescriptors is the central registry mapping every known Tab to its
// descriptor. Registering a new tab here is all that is required to make it
// participate in the tab bar, keyboard navigation, and rendering dispatch.
var tabDescriptors = map[Tab]tabDescriptor{
TabFlame: {
Name: "Flame",
ShortName: "Flm",
Position: 10,
AllowedVizModes: []tabVizMode{tabVizModeTable},
// Use the model method so the configured fastRefreshEvery interval
// is honoured on the very first tick, not just on subsequent ticks.
InitCmd: func(m *Model) tea.Cmd { return m.flameTickCmd() },
Render: tabRenderFlame,
HandleScroll: nil,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.One },
},
TabOverview: {
Name: "Overview",
ShortName: "Ovr",
Position: 20,
AllowedVizModes: []tabVizMode{tabVizModeTable},
Render: tabRenderOverview,
HandleScroll: nil,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Two },
},
TabSyscalls: {
Name: "Syscalls",
ShortName: "Sys",
Position: 30,
AllowedVizModes: []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap},
Render: tabRenderSyscalls,
HandleScroll: tabScrollSyscalls,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Three },
},
TabNonIO: {
Name: "Non-IO",
ShortName: "NIO",
Position: 75,
AllowedVizModes: []tabVizMode{tabVizModeTable},
Render: tabRenderNonIO,
HandleScroll: tabScrollNonIO,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Eight },
},
TabFiles: {
Name: "Files",
ShortName: "Fil",
Position: 40,
AllowedVizModes: []tabVizMode{tabVizModeTable},
Render: tabRenderFiles,
HandleScroll: tabScrollFiles,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Four },
},
TabProcesses: {
Name: "Processes",
ShortName: "Pro",
Position: 50,
AllowedVizModes: []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap},
Render: tabRenderProcesses,
HandleScroll: tabScrollProcesses,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Five },
},
TabLatency: {
Name: "Latency+Gaps",
ShortName: "Lat",
Position: 60,
AllowedVizModes: []tabVizMode{tabVizModeTable},
Render: tabRenderLatency,
HandleScroll: nil,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Six },
},
TabStream: {
Name: "Stream",
ShortName: "Str",
Position: 70,
AllowedVizModes: []tabVizMode{tabVizModeTable},
// Use the model method so the configured fastRefreshEvery interval
// is honoured on the very first tick, not just on subsequent ticks.
InitCmd: func(m *Model) tea.Cmd { return m.streamTickCmd() },
Render: tabRenderStream,
HandleScroll: tabScrollStream,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Seven },
},
}
// orderedTabs returns all registered tabs sorted by their Position field.
// This is the canonical tab order used for tab bar rendering and navigation.
// It replaces the hardcoded allTabs slice so new tabs registered in
// tabDescriptors automatically appear in the correct position.
func orderedTabs() []Tab {
tabs := make([]Tab, 0, len(tabDescriptors))
for tab := range tabDescriptors {
tabs = append(tabs, tab)
}
sort.Slice(tabs, func(i, j int) bool {
return tabDescriptors[tabs[i]].Position < tabDescriptors[tabs[j]].Position
})
return tabs
}
// lookupTab returns the descriptor for the given tab, falling back to a
// sensible default when the tab is not in the registry.
func lookupTab(tab Tab) tabDescriptor {
if d, ok := tabDescriptors[tab]; ok {
return d
}
return tabDescriptor{Name: "Unknown", ShortName: "Unk", AllowedVizModes: []tabVizMode{tabVizModeTable}}
}
// tabForShortcutKey searches the registry for the first tab whose ShortcutKey
// matches the given key press message. It returns the tab and true when a match
// is found; otherwise the zero Tab value and false. Using the registry here
// means adding a new tab with a shortcut only requires a new entry in
// tabDescriptors — handleShortcutKey in model.go never needs updating.
func tabForShortcutKey(msg tea.KeyPressMsg, keys common.KeyMap) (Tab, bool) {
for _, tab := range orderedTabs() {
d := tabDescriptors[tab]
if d.ShortcutKey == nil {
continue
}
if key.Matches(msg, d.ShortcutKey(keys)) {
return tab, true
}
}
return 0, false
}
// tabAllowedVizModes returns the visualisation modes allowed for tab,
// respecting any runtime conditions (e.g. Files requires dir-grouped mode for
// non-table views). This replaces the former allowedVizModes switch statement.
func tabAllowedVizModes(tab Tab, filesDirGrouped bool) []tabVizMode {
if tab == TabFiles && filesDirGrouped {
return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap, tabVizModeIcicle}
}
return lookupTab(tab).AllowedVizModes
}
// tabRenderFlame adapts the flame model's View to the tabRenderFn signature.
func tabRenderFlame(_ *Model, _ *statsengine.Snapshot, _ *eventstream.Model, flame *flamegraphtui.Model, _, _ int) string {
if flame == nil {
return common.PanelStyle.Render("Flame: waiting for model...")
}
return flame.View().Content
}
// tabRenderOverview adapts renderOverview to the tabRenderFn signature.
func tabRenderOverview(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
return renderOverview(snap, width, height)
}
// tabRenderSyscalls adapts renderSyscalls to the tabRenderFn signature.
// Sort-state rendering is handled by renderActiveContentTable before this path.
func tabRenderSyscalls(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
return renderSyscalls(snap, width, height)
}
// tabRenderNonIO adapts renderNonIO to the tabRenderFn signature.
// Offset rendering is handled by renderActiveContentTable before this path.
func tabRenderNonIO(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
return renderNonIO(snap, width, height)
}
// tabRenderFiles adapts renderFiles to the tabRenderFn signature, choosing
// between the dir-grouped and plain view based on model state.
// Sort-state rendering is handled by renderActiveContentTable before this path.
func tabRenderFiles(m *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
if m.filesDirGrouped {
return renderFilesDirGrouped(snap, width, height, m.filesDirOffset, m.filesDirCol)
}
return renderFilesWithOffset(snap, width, height, m.filesOffset, m.filesCol)
}
// tabRenderProcesses adapts renderProcessesWithOffset to the tabRenderFn signature.
func tabRenderProcesses(m *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
return renderProcessesWithOffset(snap, width, height, m.processesOffset, m.processesCol, m.pidFilter)
}
// tabRenderLatency adapts renderLatencyGapsTab to the tabRenderFn signature.
func tabRenderLatency(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
return renderLatencyGapsTab(snap, width, height)
}
// tabRenderStream adapts the stream model's View to the tabRenderFn signature.
func tabRenderStream(_ *Model, _ *statsengine.Snapshot, stream *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
if stream == nil {
return common.PanelStyle.Render("Stream: waiting for source...")
}
return stream.View(width, height)
}
// tabScrollSyscalls handles navigation keys for the syscalls tab. When the
// treemap viz is active it uses offset-based navigation; otherwise table nav.
func tabScrollSyscalls(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
keyStr := msg.String()
if m.syscallsVizMode == tabVizModeTreemap {
return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil
}
return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol,
m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
// tabScrollNonIO handles navigation keys for the non-IO family table.
func tabScrollNonIO(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
keyStr := msg.String()
return common.HandleTableNavigationKey(keyStr, &m.nonIOOffset, &m.nonIOCol,
m.maxNonIORows(), len(nonIOColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
// tabScrollFiles handles navigation keys for the files tab, selecting between
// the dir-grouped and plain navigation paths based on model state.
func tabScrollFiles(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
keyStr := msg.String()
if m.filesDirGrouped {
return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol,
m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol,
m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
// tabScrollProcesses handles navigation keys for the processes tab.
func tabScrollProcesses(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
keyStr := msg.String()
return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol,
m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil
}
// tabScrollStream handles navigation, filter, and editor-open keys for the
// stream tab. It delegates to streamModel and then emits the appropriate
// Bubble Tea messages for any filter or editor requests.
func tabScrollStream(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
streamWidth, streamHeight := streamViewport(m.width, m.height)
m.streamModel.SetViewport(streamWidth, streamHeight)
handled := m.streamModel.HandleTeaKey(msg)
if m.streamModel.ConsumeGlobalFilterUndoRequest() {
return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} }
}
if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok {
return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
}
if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok {
return openStreamEditor(m, path)
}
return handled, nil
}
// openStreamEditor opens an external editor for the given path, recording any
// open error into the stream model's status message so the user sees feedback.
func openStreamEditor(m *Model, path string) (bool, tea.Cmd) {
editorCmd, err := eventstream.EditorCommandForPath(path)
if err != nil {
m.streamModel.SetStatusMessage("Open failed: " + err.Error())
return true, nil
}
return true, tea.ExecProcess(editorCmd, func(err error) tea.Msg {
return streamEditorDoneMsg{err: err}
})
}
|