diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-08 22:35:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-08 22:35:34 +0200 |
| commit | d8bf918e83515f48564e0d0b98d30907944a1e0d (patch) | |
| tree | ed155b0c365b0d322494a96ff799c3a59a1d9ec8 /internal | |
| parent | 9f21f1004beeac10be9223bc8c5514261e397b6e (diff) | |
tui: unify table navigation and rendering
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/common/keys.go | 26 | ||||
| -rw-r--r-- | internal/tui/common/styles.go | 21 | ||||
| -rw-r--r-- | internal/tui/common/table.go | 157 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 90 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 114 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 144 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 40 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 55 | ||||
| -rw-r--r-- | internal/tui/dashboard/table.go | 46 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 56 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 88 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/tui.go | 6 |
14 files changed, 624 insertions, 223 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index f2e1271..6289997 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -110,13 +110,14 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, helpTextBinding("space", "stream pause"), - helpTextBinding("enter", "stream push filter"), + helpTextBinding("enter", "selected filter"), helpTextBinding("esc", "stream undo filter"), - helpTextBinding("g/G", "stream top/tail"), - helpTextBinding("left/right", "stream col"), - helpTextBinding("h/l", "stream col"), - helpTextBinding("j/k", "scroll"), - helpTextBinding("up/down", "scroll"), + helpTextBinding("g/G", "table top/bottom"), + helpTextBinding("left/right", "table col"), + helpTextBinding("h/l", "table col"), + helpTextBinding("j/k", "table row"), + helpTextBinding("up/down", "table row"), + helpTextBinding("pgup/down", "table page"), helpTextBinding("/,?", "stream regex search"), helpTextBinding("n/N", "stream search next/prev"), helpTextBinding("x", "stream export"), @@ -144,13 +145,14 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { controls, { helpTextBinding("space", "stream pause"), - helpTextBinding("enter", "stream push filter"), + helpTextBinding("enter", "selected filter"), helpTextBinding("esc", "stream undo filter"), - helpTextBinding("g/G", "stream top/tail"), - helpTextBinding("left/right", "stream col"), - helpTextBinding("h/l", "stream col"), - helpTextBinding("j/k", "scroll"), - helpTextBinding("up/down", "scroll"), + helpTextBinding("g/G", "table top/bottom"), + helpTextBinding("left/right", "table col"), + helpTextBinding("h/l", "table col"), + helpTextBinding("j/k", "table row"), + helpTextBinding("up/down", "table row"), + helpTextBinding("pgup/down", "table page"), helpTextBinding("x", "stream export"), helpTextBinding("X", "stream export as"), helpTextBinding("E", "stream open last"), diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go index a71ef81..111927e 100644 --- a/internal/tui/common/styles.go +++ b/internal/tui/common/styles.go @@ -77,6 +77,15 @@ var ( // ErrorStyle is used for fatal or warning messages. ErrorStyle lipgloss.Style + + // TableHeaderStyle is used by shared table headers. + TableHeaderStyle lipgloss.Style + + // TableSelectedRowStyle highlights the selected row in shared tables. + TableSelectedRowStyle lipgloss.Style + + // TableSelectedCellStyle highlights the selected cell in shared tables. + TableSelectedCellStyle lipgloss.Style ) // ApplyPalette updates shared colors and styles to match the provided theme. @@ -110,6 +119,18 @@ func ApplyPalette(isDark bool) { BorderForeground(ColorPanel) HighlightStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent) ErrorStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorDanger) + TableHeaderStyle = lipgloss.NewStyle(). + Foreground(ColorMuted). + BorderTop(true). + BorderForeground(ColorPanel) + TableSelectedRowStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorBackground). + Background(ColorPrimary) + TableSelectedCellStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorBackground). + Background(ColorAccent) } func init() { diff --git a/internal/tui/common/table.go b/internal/tui/common/table.go new file mode 100644 index 0000000..f4c94e8 --- /dev/null +++ b/internal/tui/common/table.go @@ -0,0 +1,157 @@ +package common + +import ( + "strings" + "unicode/utf8" + + "charm.land/lipgloss/v2" +) + +// TableColumn defines one shared TUI table column. +type TableColumn struct { + Title string + Width int +} + +// RenderTableHeader renders a shared table header row. +func RenderTableHeader(columns []TableColumn) string { + cells := make([]string, 0, len(columns)) + for _, col := range columns { + cells = append(cells, renderTableCell(col.Title, col.Width)) + } + return TableHeaderStyle.Render(strings.Join(cells, " ")) +} + +// RenderTableRow renders one shared TUI table row. +func RenderTableRow(columns []TableColumn, cells []string, selected bool, selectedCol int, baseStyle lipgloss.Style) string { + rendered := make([]string, 0, len(columns)) + for idx, col := range columns { + value := "" + if idx < len(cells) { + value = cells[idx] + } + cell := renderTableCell(value, col.Width) + switch { + case selected && idx == selectedCol: + cell = TableSelectedCellStyle.Render(cell) + case selected: + cell = TableSelectedRowStyle.Render(cell) + } + rendered = append(rendered, cell) + } + row := strings.Join(rendered, " ") + if selected { + return row + } + return baseStyle.Render(row) +} + +// HandleTableNavigationKey applies shared row/column movement for table views. +func HandleTableNavigationKey(keyStr string, row, col *int, rowCount, colCount, pageStep int) bool { + switch keyStr { + case "down", "j": + *row = clampIndex(*row+1, rowCount) + return true + case "up", "k": + *row = clampIndex(*row-1, rowCount) + return true + case "left", "h": + *col = clampIndex(*col-1, colCount) + return true + case "right", "l": + *col = clampIndex(*col+1, colCount) + return true + case "g": + *row = clampIndex(0, rowCount) + return true + case "G": + *row = clampIndex(rowCount-1, rowCount) + return true + case "pgup", "pageup": + if pageStep < 1 { + pageStep = 1 + } + *row = clampIndex(*row-pageStep, rowCount) + return true + case "pgdown", "pgdn", "pagedown": + if pageStep < 1 { + pageStep = 1 + } + *row = clampIndex(*row+pageStep, rowCount) + return true + default: + return false + } +} + +// VisibleTableWindow returns a centered visible row range for the selection. +func VisibleTableWindow(selectedRow, rowCount, visibleRows int) (int, int) { + if rowCount <= 0 { + return 0, 0 + } + if visibleRows <= 0 || visibleRows >= rowCount { + return 0, rowCount + } + selectedRow = clampIndex(selectedRow, rowCount) + start := selectedRow - visibleRows/2 + if start < 0 { + start = 0 + } + maxStart := rowCount - visibleRows + if start > maxStart { + start = maxStart + } + return start, start + visibleRows +} + +// ClampTableCol clamps a selected column index for the current table shape. +func ClampTableCol(col, colCount int) int { + return clampIndex(col, colCount) +} + +func renderTableCell(value string, width int) string { + if width <= 0 { + return "" + } + value = sanitizeTableCell(value) + value = truncateTableCell(value, width) + cellWidth := utf8.RuneCountInString(value) + if cellWidth >= width { + return value + } + return value + strings.Repeat(" ", width-cellWidth) +} + +func sanitizeTableCell(value string) string { + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\t", " ") + return value +} + +func truncateTableCell(value string, width int) string { + if width <= 0 { + return "" + } + if utf8.RuneCountInString(value) <= width { + return value + } + if width <= 3 { + return string([]rune(value)[:width]) + } + runes := []rune(value) + return string(runes[:width-3]) + "..." +} + +func clampIndex(value, count int) int { + if count <= 0 { + return 0 + } + if value < 0 { + return 0 + } + if value >= count { + return count - 1 + } + return value +} diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index e9bb218..e868e3a 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -1,14 +1,12 @@ package dashboard import ( - "fmt" "path/filepath" "sort" "strconv" "ior/internal/statsengine" - - "charm.land/bubbles/v2/table" + common "ior/internal/tui/common" ) type DirSnapshot struct { @@ -24,10 +22,10 @@ type DirSnapshot struct { } func renderFiles(snap *statsengine.Snapshot, width, height int) string { - return renderFilesWithOffset(snap, width, height, 0) + return renderFilesWithOffset(snap, width, height, 0, 0) } -func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int) string { +func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string { if snap == nil { return "Files: waiting for stats..." } @@ -38,28 +36,11 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int return "Files: no data" } - columns := []table.Column{ - {Title: "Accesses", Width: 8}, - {Title: "Read", Width: 9}, - {Title: "Write", Width: 9}, - {Title: "Avg Latency", Width: 11}, - {Title: "Max Latency", Width: 11}, - {Title: "Path", Width: pathWidth}, - } - - tbl := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - tbl.SetHeight(syscallTableHeight(height)) - tbl.SetWidth(tableWidth(width)) - cursor := clampOffset(offset, len(rows)) - tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:mode in dirs]", cursor+1, len(rows)) + columns := fileColumns(width) + return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:dirs", "v:mode in dirs") } -func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int) string { +func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string { if snap == nil { return "Files (dirs): waiting for stats..." } @@ -70,32 +51,14 @@ func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int return "Files (dirs): no data" } - columns := []table.Column{ - {Title: "Accesses", Width: 8}, - {Title: "Read", Width: 9}, - {Title: "Write", Width: 9}, - {Title: "Avg Latency", Width: 11}, - {Title: "Max Latency", Width: 11}, - {Title: "Files", Width: 5}, - {Title: "Directory", Width: pathWidth}, - } - - tbl := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - tbl.SetHeight(syscallTableHeight(height)) - tbl.SetWidth(tableWidth(width)) - cursor := clampOffset(offset, len(rows)) - tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:mode] [b:metric]", cursor+1, len(rows)) + columns := fileDirColumns(width) + return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:files", "v:mode", "b:metric") } -func fileRows(files []statsengine.FileSnapshot, pathWidth int) []table.Row { - rows := make([]table.Row, 0, len(files)) +func fileRows(files []statsengine.FileSnapshot, pathWidth int) [][]string { + rows := make([][]string, 0, len(files)) for _, f := range files { - rows = append(rows, table.Row{ + rows = append(rows, []string{ strconv.FormatUint(f.Accesses, 10), formatBytes(float64(f.BytesRead)), formatBytes(float64(f.BytesWritten)), @@ -132,6 +95,31 @@ func dirPathWidth(width int) int { return w } +func fileColumns(width int) []common.TableColumn { + pathWidth := filePathWidth(width) + return []common.TableColumn{ + {Title: "Accesses", Width: 8}, + {Title: "Read", Width: 9}, + {Title: "Write", Width: 9}, + {Title: "Avg Latency", Width: 11}, + {Title: "Max Latency", Width: 11}, + {Title: "Path", Width: pathWidth}, + } +} + +func fileDirColumns(width int) []common.TableColumn { + pathWidth := dirPathWidth(width) + return []common.TableColumn{ + {Title: "Accesses", Width: 8}, + {Title: "Read", Width: 9}, + {Title: "Write", Width: 9}, + {Title: "Avg Latency", Width: 11}, + {Title: "Max Latency", Width: 11}, + {Title: "Files", Width: 5}, + {Title: "Directory", Width: pathWidth}, + } +} + func truncatePathMiddle(path string, limit int) string { if len(path) <= limit { return path @@ -187,10 +175,10 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot { return out } -func dirRows(dirs []DirSnapshot, pathWidth int) []table.Row { - rows := make([]table.Row, 0, len(dirs)) +func dirRows(dirs []DirSnapshot, pathWidth int) [][]string { + rows := make([][]string, 0, len(dirs)) for _, d := range dirs { - rows = append(rows, table.Row{ + rows = append(rows, []string{ strconv.FormatUint(d.Accesses, 10), formatBytes(float64(d.BytesRead)), formatBytes(float64(d.BytesWritten)), diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 1ffb6e8..3a19b74 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -70,11 +70,15 @@ type Model struct { filterStack []string pidFilter int syscallsOffset int + syscallsCol int syscallsTreemapSelection int filesOffset int + filesCol int filesDirGrouped bool filesDirOffset int + filesDirCol int processesOffset int + processesCol int syscallsVizMode tabVizMode filesVizMode tabVizMode processesVizMode tabVizMode @@ -164,6 +168,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height + m.clampTableColumns() streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) m.streamModel.SetViewport(streamWidth, streamHeight) flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) @@ -226,6 +231,7 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) { m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows()) m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRowsForMode()) m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) + m.clampTableColumns() m.streamModel.Refresh() if m.refreshBubbleData() { return m, bubbleTickCmdFn() @@ -279,6 +285,24 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { return false, nil } switch m.activeTab { + case TabSyscalls: + if m.syscallsVizMode != tabVizModeTable { + return false, nil + } + filter, action, ok := m.selectedSyscallFilter() + if !ok { + return false, nil + } + return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} } + case TabFiles: + if m.filesVizMode != tabVizModeTable { + return false, nil + } + filter, action, ok := m.selectedFileFilter() + if !ok { + return false, nil + } + return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} } case TabProcesses: filter, action, ok := m.selectedProcessFilter() if !ok { @@ -290,6 +314,52 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { } } +func (m Model) selectedSyscallFilter() (globalfilter.Filter, string, bool) { + if m.latest == nil || m.syscallsOffset < 0 { + return globalfilter.Filter{}, "", false + } + rows := m.latest.Syscalls() + if len(rows) == 0 { + return globalfilter.Filter{}, "", false + } + selected := rows[clampOffset(m.syscallsOffset, len(rows))] + if strings.TrimSpace(selected.Name) == "" { + return globalfilter.Filter{}, "", false + } + filter := m.globalFilter.Clone() + filter.Syscall = &globalfilter.StringFilter{Pattern: selected.Name} + return filter, "syscall~" + selected.Name, true +} + +func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) { + if m.latest == nil { + return globalfilter.Filter{}, "", false + } + filter := m.globalFilter.Clone() + if m.filesDirGrouped { + dirs := aggregateFilesByDir(m.latest.Files()) + if len(dirs) == 0 { + return globalfilter.Filter{}, "", false + } + selected := dirs[clampOffset(m.filesDirOffset, len(dirs))] + if strings.TrimSpace(selected.Dir) == "" { + return globalfilter.Filter{}, "", false + } + filter.File = &globalfilter.StringFilter{Pattern: selected.Dir} + return filter, "file~" + selected.Dir, true + } + files := m.latest.Files() + if len(files) == 0 { + return globalfilter.Filter{}, "", false + } + selected := files[clampOffset(m.filesOffset, len(files))] + if strings.TrimSpace(selected.Path) == "" { + return globalfilter.Filter{}, "", false + } + filter.File = &globalfilter.StringFilter{Pattern: selected.Path} + return filter, "file~" + selected.Path, true +} + func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) { if msg.String() != "H" { return false, m, nil @@ -380,9 +450,15 @@ func (m Model) selectedProcessFilter() (globalfilter.Filter, string, bool) { return globalfilter.Filter{}, "", false } filter := m.globalFilter.Clone() + if m.processesCol == 1 { + comm := strings.TrimSpace(proc.Comm) + if comm != "" { + filter.Comm = &globalfilter.StringFilter{Pattern: comm} + return filter, "comm~" + comm, true + } + } filter.PID = &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: int64(proc.PID)} - action := fmt.Sprintf("pid=%d", proc.PID) - return filter, action, true + return filter, fmt.Sprintf("pid=%d", proc.PID), true } func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) { @@ -515,14 +591,14 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { if m.syscallsVizMode == tabVizModeTreemap { return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil } - return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil + return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol, m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil case TabFiles: if m.filesDirGrouped { - return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRowsForMode()), nil + return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol, m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil } - return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()), nil + return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol, m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil case TabProcesses: - return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()), nil + return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol, m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil case TabStream: streamWidth, streamHeight := streamViewport(m.width, m.height) m.streamModel.SetViewport(streamWidth, streamHeight) @@ -566,6 +642,13 @@ func scrollOffset(keyStr string, offset *int, maxRows int) bool { } } +func (m *Model) clampTableColumns() { + m.syscallsCol = common.ClampTableCol(m.syscallsCol, len(syscallColumns(m.width))) + m.filesCol = common.ClampTableCol(m.filesCol, len(fileColumns(m.width))) + m.filesDirCol = common.ClampTableCol(m.filesDirCol, len(fileDirColumns(m.width))) + m.processesCol = common.ClampTableCol(m.processesCol, len(processColumns())) +} + func (m Model) maxSyscallsRows() int { if m.latest == nil { return 0 @@ -775,13 +858,22 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst activeHeight, m.pidFilter, m.syscallsOffset, + m.syscallsCol, m.filesOffset, + m.filesCol, m.filesDirGrouped, m.filesDirOffset, + m.filesDirCol, m.processesOffset, + m.processesCol, ) } +func (m Model) activeTableHeight() int { + _, activeHeight := flameViewport(m.width, m.height, m.showHelp) + return activeHeight +} + func (m *Model) setBubbleViewports(width, height int) { m.syscallsChart.SetViewport(width, height) m.filesChart.SetViewport(width, height) @@ -988,7 +1080,7 @@ func tickCmd(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} }) } -func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, syscallsCol, filesOffset, filesCol int, filesDirGrouped bool, filesDirOffset, filesDirCol, processesOffset, processesCol int) string { if tab == TabStream { if streamModel == nil { return common.PanelStyle.Render("Stream: waiting for source...") @@ -1011,14 +1103,14 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre case TabOverview: return renderOverview(snap, width, height) case TabSyscalls: - return renderSyscallsWithOffset(snap, width, height, syscallsOffset) + return renderSyscallsWithOffset(snap, width, height, syscallsOffset, syscallsCol) case TabFiles: if filesDirGrouped { - return renderFilesDirGrouped(snap, width, height, filesDirOffset) + return renderFilesDirGrouped(snap, width, height, filesDirOffset, filesDirCol) } - return renderFilesWithOffset(snap, width, height, filesOffset) + return renderFilesWithOffset(snap, width, height, filesOffset, filesCol) case TabProcesses: - return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter) + return renderProcessesWithOffset(snap, width, height, processesOffset, processesCol, pidFilter) case TabLatency: return renderLatencyGapsTab(snap, width, height) default: diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index cfeb5c8..dbcde07 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -142,6 +142,63 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { } } +func TestSyscallsTabSupportsHorizontalColumnNavigation(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + model := next.(Model) + if model.syscallsCol != 1 { + t.Fatalf("expected syscalls selected column 1 after right, got %d", model.syscallsCol) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + model = next.(Model) + if model.syscallsCol != 0 { + t.Fatalf("expected syscalls selected column 0 after left, got %d", model.syscallsCol) + } +} + +func TestFilesTabSupportsHorizontalColumnNavigation(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + model := next.(Model) + if model.filesCol != 1 { + t.Fatalf("expected files selected column 1 after right, got %d", model.filesCol) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + model = next.(Model) + if model.filesCol != 0 { + t.Fatalf("expected files selected column 0 after left, got %d", model.filesCol) + } +} + +func TestProcessesTabSupportsHorizontalColumnNavigation(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1, Comm: "alpha"}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + model := next.(Model) + if model.processesCol != 1 { + t.Fatalf("expected processes selected column 1 after right, got %d", model.processesCol) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + model = next.(Model) + if model.processesCol != 0 { + t.Fatalf("expected processes selected column 0 after left, got %d", model.processesCol) + } +} + func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabProcesses @@ -170,6 +227,35 @@ func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) { } } +func TestProcessesTabEnterCommColumnEmitsCommFilterRequest(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 111, Comm: "alpha", Syscalls: 9}, + {PID: 222, Comm: "beta", Syscalls: 4}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.processesOffset = 1 + m.processesCol = 1 + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on processes comm column to emit a filter request") + } + msg := cmd() + req, ok := msg.(messages.GlobalFilterRequestedMsg) + if !ok { + t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg) + } + if req.Filter.Comm == nil || req.Filter.Comm.Pattern != "beta" { + t.Fatalf("expected comm beta filter, got %+v", req.Filter.Comm) + } + if req.Action != "comm~beta" { + t.Fatalf("expected action comm~beta, got %q", req.Action) + } +} + func TestFilesTabScrollsWithJK(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles @@ -189,6 +275,34 @@ func TestFilesTabScrollsWithJK(t *testing.T) { } } +func TestSyscallsTabEnterEmitsGlobalFilterRequest(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ + {Name: "read", Count: 9}, + {Name: "write", Count: 4}, + }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.syscallsOffset = 1 + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on syscalls tab to emit a filter request") + } + msg := cmd() + req, ok := msg.(messages.GlobalFilterRequestedMsg) + if !ok { + t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg) + } + if req.Filter.Syscall == nil || req.Filter.Syscall.Pattern != "write" { + t.Fatalf("expected syscall write filter, got %+v", req.Filter.Syscall) + } + if req.Action != "syscall~write" { + t.Fatalf("expected action syscall~write, got %q", req.Action) + } +} + func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles @@ -210,6 +324,34 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) { } } +func TestFilesTabEnterEmitsGlobalFilterRequest(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/tmp/a"}, + {Path: "/tmp/b"}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.filesOffset = 1 + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on files tab to emit a filter request") + } + msg := cmd() + req, ok := msg.(messages.GlobalFilterRequestedMsg) + if !ok { + t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg) + } + if req.Filter.File == nil || req.Filter.File.Pattern != "/tmp/b" { + t.Fatalf("expected file /tmp/b filter, got %+v", req.Filter.File) + } + if req.Action != "file~/tmp/b" { + t.Fatalf("expected action file~/tmp/b, got %q", req.Action) + } +} + func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { rb := eventstream.NewRingBuffer() m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap()) @@ -853,7 +995,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}, ) - out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0) + out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, 0, 0, true, 0, 0, 0, 0) if !strings.Contains(out, "Directory") { t.Fatalf("expected grouped directory files view header, got %q", out) } diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index b71b22c..dbd8fbe 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -6,15 +6,14 @@ import ( "strings" "ior/internal/statsengine" - - "charm.land/bubbles/v2/table" + common "ior/internal/tui/common" ) func renderProcesses(snap *statsengine.Snapshot, width, height int) string { - return renderProcessesWithOffset(snap, width, height, 0, -1) + return renderProcessesWithOffset(snap, width, height, 0, 0, -1) } -func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, pidFilter int) string { +func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol, pidFilter int) string { if snap == nil { return "Processes: waiting for stats..." } @@ -24,7 +23,16 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset return "Processes: no data" } - columns := []table.Column{ + columns := processColumns() + out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric") + if pidFilter > 0 { + out += "\n" + "Note: this tab is most useful with All PIDs." + } + return out +} + +func processColumns() []common.TableColumn { + return []common.TableColumn{ {Title: "PID", Width: 8}, {Title: "Comm", Width: 18}, {Title: "Syscalls", Width: 10}, @@ -32,28 +40,12 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset {Title: "Total Bytes", Width: 12}, {Title: "Avg Latency", Width: 12}, } - - tbl := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - tbl.SetHeight(syscallTableHeight(height)) - tbl.SetWidth(tableWidth(width)) - cursor := clampOffset(offset, len(rows)) - tbl.SetCursor(cursor) - - out := tbl.View() + fmt.Sprintf("\nRow %d/%d [enter:filter] [v:mode] [b:metric]", cursor+1, len(rows)) - if pidFilter > 0 { - out += "\n" + "Note: this tab is most useful with All PIDs." - } - return out } -func processRows(processes []statsengine.ProcessSnapshot) []table.Row { - rows := make([]table.Row, 0, len(processes)) +func processRows(processes []statsengine.ProcessSnapshot) [][]string { + rows := make([][]string, 0, len(processes)) for _, p := range processes { - rows = append(rows, table.Row{ + rows = append(rows, []string{ strconv.FormatUint(uint64(p.PID), 10), truncateText(p.Comm, 18), strconv.FormatUint(p.Syscalls, 10), diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go index 24c1c1b..bfc26fc 100644 --- a/internal/tui/dashboard/processes_test.go +++ b/internal/tui/dashboard/processes_test.go @@ -41,7 +41,7 @@ func TestRenderProcessesShowsSinglePIDNote(t *testing.T) { statsengine.HistogramSnapshot{}, ) - out := renderProcessesWithOffset(&snap, 100, 20, 0, 77) + out := renderProcessesWithOffset(&snap, 100, 20, 0, 0, 77) if !strings.Contains(out, "most useful with All PIDs") { t.Fatalf("expected single-pid guidance note") } diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 5235b3e..9518f09 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -6,15 +6,14 @@ import ( "time" "ior/internal/statsengine" - - "charm.land/bubbles/v2/table" + common "ior/internal/tui/common" ) func renderSyscalls(snap *statsengine.Snapshot, width, height int) string { - return renderSyscallsWithOffset(snap, width, height, 0) + return renderSyscallsWithOffset(snap, width, height, 0, 0) } -func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset int) string { +func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string { if snap == nil { return |
