From 1af7cf5fe51fa13e828cdef6268348ec9cd7bd7c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 9 Mar 2026 22:41:07 +0200 Subject: tui: add sortable files dashboard table modes (task 364) --- internal/tui/dashboard/files.go | 267 ++++++++++++++++++++++++++++++++++- internal/tui/dashboard/files_test.go | 40 ++++++ internal/tui/dashboard/model.go | 147 +++++++++++++++++-- internal/tui/dashboard/model_test.go | 112 +++++++++++++++ internal/tui/dashboard/sort.go | 33 +++++ internal/tui/dashboard/syscalls.go | 33 ----- 6 files changed, 585 insertions(+), 47 deletions(-) (limited to 'internal') diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index e868e3a..25a7ea5 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -2,6 +2,7 @@ package dashboard import ( "path/filepath" + "slices" "sort" "strconv" @@ -21,38 +22,92 @@ type DirSnapshot struct { FileCount uint64 } +type fileSortKey uint8 + +const ( + fileSortKeyAccesses fileSortKey = iota + fileSortKeyRead + fileSortKeyWrite + fileSortKeyAvgLatency + fileSortKeyMaxLatency + fileSortKeyPath +) + +type fileDirSortKey uint8 + +const ( + fileDirSortKeyAccesses fileDirSortKey = iota + fileDirSortKeyRead + fileDirSortKeyWrite + fileDirSortKeyAvgLatency + fileDirSortKeyMaxLatency + fileDirSortKeyFileCount + fileDirSortKeyDir +) + func renderFiles(snap *statsengine.Snapshot, width, height int) string { - return renderFilesWithOffset(snap, width, height, 0, 0) + return renderFilesWithSort(snap, width, height, 0, 0, tableSortState[fileSortKey]{}) } func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string { + return renderFilesWithSort(snap, width, height, offset, selectedCol, tableSortState[fileSortKey]{}) +} + +func renderFilesWithSort(snap *statsengine.Snapshot, width, height, offset, selectedCol int, sortState tableSortState[fileSortKey]) string { if snap == nil { return "Files: waiting for stats..." } pathWidth := filePathWidth(width) - rows := fileRows(snap.Files(), pathWidth) + rows := fileRows(sortedFileSnapshots(snap.Files(), sortState), pathWidth) if len(rows) == 0 { return "Files: no data" } columns := fileColumns(width) - return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:dirs", "v:mode in dirs") + return renderSelectableTable( + columns, + rows, + height, + offset, + selectedCol, + "enter:filter", + "s:sort", + fileSortHint(sortState), + "d:dirs", + "v:mode in dirs", + ) } func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string { + return renderFilesDirGroupedWithSort(snap, width, height, offset, selectedCol, tableSortState[fileDirSortKey]{}) +} + +func renderFilesDirGroupedWithSort(snap *statsengine.Snapshot, width, height, offset, selectedCol int, sortState tableSortState[fileDirSortKey]) string { if snap == nil { return "Files (dirs): waiting for stats..." } pathWidth := dirPathWidth(width) - rows := dirRows(aggregateFilesByDir(snap.Files()), pathWidth) + rows := dirRows(sortedDirSnapshots(aggregateFilesByDir(snap.Files()), sortState), pathWidth) if len(rows) == 0 { return "Files (dirs): no data" } columns := fileDirColumns(width) - return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:files", "v:mode", "b:metric") + return renderSelectableTable( + columns, + rows, + height, + offset, + selectedCol, + "enter:filter", + "s:sort", + fileDirSortHint(sortState), + "d:files", + "v:mode", + "b:metric", + ) } func fileRows(files []statsengine.FileSnapshot, pathWidth int) [][]string { @@ -120,6 +175,208 @@ func fileDirColumns(width int) []common.TableColumn { } } +func sortedFileSnapshots(rows []statsengine.FileSnapshot, sortState tableSortState[fileSortKey]) []statsengine.FileSnapshot { + if len(rows) == 0 { + return nil + } + if !sortState.active { + return rows + } + + sorted := slices.Clone(rows) + slices.SortFunc(sorted, func(left, right statsengine.FileSnapshot) int { + if cmp := compareFileBySort(left, right, sortState.key); cmp != 0 { + return cmp + } + return compareFileDefault(left, right) + }) + return sorted +} + +func sortedDirSnapshots(rows []DirSnapshot, sortState tableSortState[fileDirSortKey]) []DirSnapshot { + if len(rows) == 0 { + return nil + } + if !sortState.active { + return rows + } + + sorted := slices.Clone(rows) + slices.SortFunc(sorted, func(left, right DirSnapshot) int { + if cmp := compareDirBySort(left, right, sortState.key); cmp != 0 { + return cmp + } + return compareDirDefault(left, right) + }) + return sorted +} + +func compareFileBySort(left, right statsengine.FileSnapshot, key fileSortKey) int { + switch key { + case fileSortKeyAccesses: + return compareUint64Desc(left.Accesses, right.Accesses) + case fileSortKeyRead: + return compareUint64Desc(left.BytesRead, right.BytesRead) + case fileSortKeyWrite: + return compareUint64Desc(left.BytesWritten, right.BytesWritten) + case fileSortKeyAvgLatency: + return compareFloat64Desc(left.AvgLatencyNs, right.AvgLatencyNs) + case fileSortKeyMaxLatency: + return compareUint64Desc(left.MaxLatencyNs, right.MaxLatencyNs) + case fileSortKeyPath: + return compareStringAsc(left.Path, right.Path) + default: + return 0 + } +} + +func compareFileDefault(left, right statsengine.FileSnapshot) int { + if cmp := compareUint64Desc(left.Accesses, right.Accesses); cmp != 0 { + return cmp + } + return compareStringAsc(left.Path, right.Path) +} + +func compareDirBySort(left, right DirSnapshot, key fileDirSortKey) int { + switch key { + case fileDirSortKeyAccesses: + return compareUint64Desc(left.Accesses, right.Accesses) + case fileDirSortKeyRead: + return compareUint64Desc(left.BytesRead, right.BytesRead) + case fileDirSortKeyWrite: + return compareUint64Desc(left.BytesWritten, right.BytesWritten) + case fileDirSortKeyAvgLatency: + return compareFloat64Desc(left.AvgLatencyNs, right.AvgLatencyNs) + case fileDirSortKeyMaxLatency: + return compareUint64Desc(left.MaxLatencyNs, right.MaxLatencyNs) + case fileDirSortKeyFileCount: + return compareUint64Desc(left.FileCount, right.FileCount) + case fileDirSortKeyDir: + return compareStringAsc(left.Dir, right.Dir) + default: + return 0 + } +} + +func compareDirDefault(left, right DirSnapshot) int { + if cmp := compareUint64Desc(left.Accesses, right.Accesses); cmp != 0 { + return cmp + } + return compareStringAsc(left.Dir, right.Dir) +} + +func fileSortKeyForColumn(column int) (fileSortKey, bool) { + switch column { + case 0: + return fileSortKeyAccesses, true + case 1: + return fileSortKeyRead, true + case 2: + return fileSortKeyWrite, true + case 3: + return fileSortKeyAvgLatency, true + case 4: + return fileSortKeyMaxLatency, true + case 5: + return fileSortKeyPath, true + default: + return 0, false + } +} + +func fileDirSortKeyForColumn(column int) (fileDirSortKey, bool) { + switch column { + case 0: + return fileDirSortKeyAccesses, true + case 1: + return fileDirSortKeyRead, true + case 2: + return fileDirSortKeyWrite, true + case 3: + return fileDirSortKeyAvgLatency, true + case 4: + return fileDirSortKeyMaxLatency, true + case 5: + return fileDirSortKeyFileCount, true + case 6: + return fileDirSortKeyDir, true + default: + return 0, false + } +} + +func fileSortHint(sortState tableSortState[fileSortKey]) string { + return "sort: " + fileSortLabel(sortState) +} + +func fileSortLabel(sortState tableSortState[fileSortKey]) string { + if !sortState.active { + return "default" + } + switch sortState.key { + case fileSortKeyAccesses: + return "Accesses desc" + case fileSortKeyRead: + return "Read desc" + case fileSortKeyWrite: + return "Write desc" + case fileSortKeyAvgLatency: + return "Avg Latency desc" + case fileSortKeyMaxLatency: + return "Max Latency desc" + case fileSortKeyPath: + return "Path asc" + default: + return "default" + } +} + +func fileDirSortHint(sortState tableSortState[fileDirSortKey]) string { + return "sort: " + fileDirSortLabel(sortState) +} + +func fileDirSortLabel(sortState tableSortState[fileDirSortKey]) string { + if !sortState.active { + return "default" + } + switch sortState.key { + case fileDirSortKeyAccesses: + return "Accesses desc" + case fileDirSortKeyRead: + return "Read desc" + case fileDirSortKeyWrite: + return "Write desc" + case fileDirSortKeyAvgLatency: + return "Avg Latency desc" + case fileDirSortKeyMaxLatency: + return "Max Latency desc" + case fileDirSortKeyFileCount: + return "Files desc" + case fileDirSortKeyDir: + return "Directory asc" + default: + return "default" + } +} + +func findFileOffset(rows []statsengine.FileSnapshot, path string) (int, bool) { + for idx, row := range rows { + if row.Path == path { + return idx, true + } + } + return 0, false +} + +func findDirOffset(rows []DirSnapshot, dir string) (int, bool) { + for idx, row := range rows { + if row.Dir == dir { + return idx, true + } + } + return 0, false +} + func truncatePathMiddle(path string, limit int) string { if len(path) <= limit { return path diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go index 1be958a..87ada56 100644 --- a/internal/tui/dashboard/files_test.go +++ b/internal/tui/dashboard/files_test.go @@ -27,6 +27,12 @@ func TestRenderFilesIncludesHeaders(t *testing.T) { t.Fatalf("expected token %q in files table output", token) } } + if !strings.Contains(out, "s:sort") { + t.Fatalf("expected files sort hint in output") + } + if !strings.Contains(out, "sort: default") { + t.Fatalf("expected files default sort label in output") + } } func TestRenderFilesNoData(t *testing.T) { @@ -106,3 +112,37 @@ func TestDirPathWidthAccountsForFilesColumn(t *testing.T) { t.Fatalf("expected dirPathWidth to reserve 6 extra chars, got dir=%d file=%d", got, filePathWidth(180)) } } + +func TestSortedFileSnapshotsUsesSelectedSortKey(t *testing.T) { + rows := []statsengine.FileSnapshot{ + {Path: "/tmp/z.log", Accesses: 9, BytesRead: 10}, + {Path: "/tmp/a.log", Accesses: 3, BytesRead: 50}, + } + + sorted := sortedFileSnapshots(rows, tableSortState[fileSortKey]{active: true, key: fileSortKeyPath}) + if sorted[0].Path != "/tmp/a.log" { + t.Fatalf("expected path sort to put /tmp/a.log first, got %q", sorted[0].Path) + } + + sorted = sortedFileSnapshots(rows, tableSortState[fileSortKey]{active: true, key: fileSortKeyRead}) + if sorted[0].Path != "/tmp/a.log" { + t.Fatalf("expected read desc sort to put /tmp/a.log first, got %q", sorted[0].Path) + } +} + +func TestSortedDirSnapshotsUsesSelectedSortKey(t *testing.T) { + rows := []DirSnapshot{ + {Dir: "/var/log", Accesses: 9, FileCount: 1}, + {Dir: "/tmp", Accesses: 3, FileCount: 4}, + } + + sorted := sortedDirSnapshots(rows, tableSortState[fileDirSortKey]{active: true, key: fileDirSortKeyDir}) + if sorted[0].Dir != "/tmp" { + t.Fatalf("expected dir sort to put /tmp first, got %q", sorted[0].Dir) + } + + sorted = sortedDirSnapshots(rows, tableSortState[fileDirSortKey]{active: true, key: fileDirSortKeyFileCount}) + if sorted[0].Dir != "/tmp" { + t.Fatalf("expected file-count sort to put /tmp first, got %q", sorted[0].Dir) + } +} diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 7fea1c4..e418367 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -75,9 +75,11 @@ type Model struct { syscallsTreemapSelection int filesOffset int filesCol int + filesSort tableSortState[fileSortKey] filesDirGrouped bool filesDirOffset int filesDirCol int + filesDirSort tableSortState[fileDirSortKey] processesOffset int processesCol int syscallsVizMode tabVizMode @@ -227,14 +229,24 @@ func (m Model) handleBubbleTick() (tea.Model, tea.Cmd) { func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) { selectedSyscall := "" + selectedFile := "" + selectedDir := "" if m.syscallsSort.active { selectedSyscall = m.selectedSyscallName() } + if m.filesVizMode == tabVizModeTable { + if !m.filesDirGrouped && m.filesSort.active { + selectedFile = m.selectedFilePath() + } + if m.filesDirGrouped && m.filesDirSort.active { + selectedDir = m.selectedDirPath() + } + } m.latest = msg.Snap m.reanchorSyscallsOffset(selectedSyscall) + m.reanchorFilesOffset(selectedFile) + m.reanchorFilesDirOffset(selectedDir) m.syscallsTreemapSelection = clampOffset(m.syscallsTreemapSelection, m.maxSyscallsRows()) - 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() @@ -363,7 +375,18 @@ func (m *Model) handleSortKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { if !key.Matches(msg, m.keys.Sort) { return false, nil } - if m.activeTab != TabSyscalls || m.syscallsVizMode != tabVizModeTable { + switch m.activeTab { + case TabSyscalls: + return m.handleSyscallsSortKey() + case TabFiles: + return m.handleFilesSortKey() + default: + return false, nil + } +} + +func (m *Model) handleSyscallsSortKey() (bool, tea.Cmd) { + if m.syscallsVizMode != tabVizModeTable { return false, nil } key, ok := syscallSortKeyForColumn(m.width, m.syscallsCol) @@ -376,6 +399,30 @@ func (m *Model) handleSortKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { return true, nil } +func (m *Model) handleFilesSortKey() (bool, tea.Cmd) { + if m.filesVizMode != tabVizModeTable { + return false, nil + } + if m.filesDirGrouped { + key, ok := fileDirSortKeyForColumn(m.filesDirCol) + if !ok { + return false, nil + } + selectedDir := m.selectedDirPath() + m.filesDirSort = m.filesDirSort.toggled(key) + m.reanchorFilesDirOffset(selectedDir) + return true, nil + } + key, ok := fileSortKeyForColumn(m.filesCol) + if !ok { + return false, nil + } + selectedPath := m.selectedFilePath() + m.filesSort = m.filesSort.toggled(key) + m.reanchorFilesOffset(selectedPath) + return true, nil +} + func (m *Model) reanchorSyscallsOffset(selectedName string) { rows := m.sortedSyscallRows() if len(rows) == 0 { @@ -391,28 +438,56 @@ func (m *Model) reanchorSyscallsOffset(selectedName string) { m.syscallsOffset = clampOffset(m.syscallsOffset, len(rows)) } +func (m *Model) reanchorFilesOffset(selectedPath string) { + rows := m.sortedFileRows() + if len(rows) == 0 { + m.filesOffset = 0 + return + } + if selectedPath != "" { + if index, ok := findFileOffset(rows, selectedPath); ok { + m.filesOffset = index + return + } + } + m.filesOffset = clampOffset(m.filesOffset, len(rows)) +} + +func (m *Model) reanchorFilesDirOffset(selectedDir string) { + rows := m.sortedDirRows() + if len(rows) == 0 { + m.filesDirOffset = 0 + return + } + if selectedDir != "" { + if index, ok := findDirOffset(rows, selectedDir); ok { + m.filesDirOffset = index + return + } + } + m.filesDirOffset = clampOffset(m.filesDirOffset, len(rows)) +} + 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 { + selected, ok := m.selectedDirSnapshot() + if !ok { 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 { + selected, ok := m.selectedFileSnapshot() + if !ok { return globalfilter.Filter{}, "", false } - selected := files[clampOffset(m.filesOffset, len(files))] if strings.TrimSpace(selected.Path) == "" { return globalfilter.Filter{}, "", false } @@ -420,6 +495,54 @@ func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) { return filter, "file~" + selected.Path, true } +func (m Model) selectedFileSnapshot() (statsengine.FileSnapshot, bool) { + rows := m.sortedFileRows() + if len(rows) == 0 { + return statsengine.FileSnapshot{}, false + } + index := clampOffset(m.filesOffset, len(rows)) + return rows[index], true +} + +func (m Model) sortedFileRows() []statsengine.FileSnapshot { + if m.latest == nil { + return nil + } + return sortedFileSnapshots(m.latest.Files(), m.filesSort) +} + +func (m Model) selectedFilePath() string { + selected, ok := m.selectedFileSnapshot() + if !ok { + return "" + } + return selected.Path +} + +func (m Model) selectedDirSnapshot() (DirSnapshot, bool) { + rows := m.sortedDirRows() + if len(rows) == 0 { + return DirSnapshot{}, false + } + index := clampOffset(m.filesDirOffset, len(rows)) + return rows[index], true +} + +func (m Model) sortedDirRows() []DirSnapshot { + if m.latest == nil { + return nil + } + return sortedDirSnapshots(aggregateFilesByDir(m.latest.Files()), m.filesDirSort) +} + +func (m Model) selectedDirPath() string { + selected, ok := m.selectedDirSnapshot() + if !ok { + return "" + } + return selected.Dir +} + func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) { if msg.String() != "H" { return false, m, nil @@ -917,6 +1040,12 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst if m.activeTab == TabSyscalls && m.latest != nil { return renderSyscallsWithSort(m.latest, width, activeHeight, m.syscallsOffset, m.syscallsCol, m.syscallsSort) } + if m.activeTab == TabFiles && m.latest != nil && m.filesVizMode == tabVizModeTable { + if m.filesDirGrouped { + return renderFilesDirGroupedWithSort(m.latest, width, activeHeight, m.filesDirOffset, m.filesDirCol, m.filesDirSort) + } + return renderFilesWithSort(m.latest, width, activeHeight, m.filesOffset, m.filesCol, m.filesSort) + } return renderActiveTab( m.activeTab, m.latest, diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 76a60e3..aa9b774 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -503,6 +503,118 @@ func TestFilesTabEnterEmitsGlobalFilterRequest(t *testing.T) { } } +func TestFilesSortKeyTogglesFlatMode(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/tmp/z.log", Accesses: 9}, + {Path: "/tmp/a.log", Accesses: 3}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.filesCol = 5 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + model := next.(Model) + if !model.filesSort.active || model.filesSort.key != fileSortKeyPath { + t.Fatalf("expected flat file path sort enabled, got %+v", model.filesSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + model = next.(Model) + if model.filesSort.active { + t.Fatalf("expected second s press to restore default file ordering") + } +} + +func TestFilesSortEnterUsesSortedVisibleRow(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/tmp/z.log", Accesses: 9}, + {Path: "/tmp/a.log", Accesses: 3}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.filesOffset = 1 + m.filesCol = 5 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on sorted 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/a.log" { + t.Fatalf("expected visible sorted row to filter /tmp/a.log, got %+v", req.Filter.File) + } +} + +func TestFilesDirSortEnterUsesSortedVisibleRow(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + m.filesDirGrouped = true + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/var/log/z.log", Accesses: 9}, + {Path: "/tmp/a.log", Accesses: 3}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.filesDirOffset = 1 + m.filesDirCol = 6 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on sorted grouped 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" { + t.Fatalf("expected visible sorted grouped row to filter /tmp, got %+v", req.Filter.File) + } +} + +func TestFilesSortStatesPersistAcrossDirToggle(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/var/log/z.log", Accesses: 9}, + {Path: "/tmp/a.log", Accesses: 3}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.filesCol = 5 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) + m = next.(Model) + m.filesDirCol = 6 + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + m = next.(Model) + + if !m.filesSort.active || m.filesSort.key != fileSortKeyPath { + t.Fatalf("expected flat file sort state preserved, got %+v", m.filesSort) + } + if !m.filesDirSort.active || m.filesDirSort.key != fileDirSortKeyDir { + t.Fatalf("expected dir sort state enabled, got %+v", m.filesDirSort) + } + + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) + m = next.(Model) + if !m.filesSort.active || m.filesSort.key != fileSortKeyPath { + t.Fatalf("expected flat file sort state after returning from dir mode, got %+v", m.filesSort) + } +} + func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) { rb := eventstream.NewRingBuffer() m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap()) diff --git a/internal/tui/dashboard/sort.go b/internal/tui/dashboard/sort.go index acb19d0..5c4fe54 100644 --- a/internal/tui/dashboard/sort.go +++ b/internal/tui/dashboard/sort.go @@ -11,3 +11,36 @@ func (s tableSortState[K]) toggled(key K) tableSortState[K] { } return tableSortState[K]{active: true, key: key} } + +func compareUint64Desc(left, right uint64) int { + switch { + case left > right: + return -1 + case left < right: + return 1 + default: + return 0 + } +} + +func compareFloat64Desc(left, right float64) int { + switch { + case left > right: + return -1 + case left < right: + return 1 + default: + return 0 + } +} + +func compareStringAsc(left, right string) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 78eed0c..8f2530a 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -149,39 +149,6 @@ func compareSyscallDefault(left, right statsengine.SyscallSnapshot) int { return compareStringAsc(left.Name, right.Name) } -func compareUint64Desc(left, right uint64) int { - switch { - case left > right: - return -1 - case left < right: - return 1 - default: - return 0 - } -} - -func compareFloat64Desc(left, right float64) int { - switch { - case left > right: - return -1 - case left < right: - return 1 - default: - return 0 - } -} - -func compareStringAsc(left, right string) int { - switch { - case left < right: - return -1 - case left > right: - return 1 - default: - return 0 - } -} - func syscallSortKeyForColumn(width, column int) (syscallSortKey, bool) { if width < 140 { return compactSyscallSortKey(column) -- cgit v1.2.3