summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/dashboard/files.go267
-rw-r--r--internal/tui/dashboard/files_test.go40
-rw-r--r--internal/tui/dashboard/model.go147
-rw-r--r--internal/tui/dashboard/model_test.go112
-rw-r--r--internal/tui/dashboard/sort.go33
-rw-r--r--internal/tui/dashboard/syscalls.go33
6 files changed, 585 insertions, 47 deletions
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)