diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-09 23:01:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-09 23:01:38 +0200 |
| commit | 227de0db390fec4e1327a7cab6be4c1268848695 (patch) | |
| tree | f70ff9f3b23db47db0e0aeafa1bb1aad5abc71a8 /internal | |
| parent | bcaa22111ac619e317f7adfd60a1fc6bd4db8d29 (diff) | |
tui: add reverse sorting for dashboard tables (task 364)
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/common/keys.go | 98 | ||||
| -rw-r--r-- | internal/tui/common/keys_test.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 44 | ||||
| -rw-r--r-- | internal/tui/dashboard/files_test.go | 12 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 92 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 21 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes_test.go | 7 | ||||
| -rw-r--r-- | internal/tui/dashboard/sort.go | 29 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 31 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls_test.go | 7 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 3 | ||||
| -rw-r--r-- | internal/tui/tui.go | 1 |
13 files changed, 279 insertions, 112 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 87edec4..2e54a6d 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -10,29 +10,30 @@ type HelpSection struct { // KeyMap groups all key bindings shared by TUI screens. type KeyMap struct { - Tab key.Binding - ShiftTab key.Binding - One key.Binding - Two key.Binding - Three key.Binding - Four key.Binding - Five key.Binding - Six key.Binding - Seven key.Binding - Visualize key.Binding - Metric key.Binding - Sort key.Binding - DirGroup key.Binding - SelectPID key.Binding - SelectTID key.Binding - Probes key.Binding - Filter key.Binding - FilterUndo key.Binding - Export key.Binding - Quit key.Binding - Enter key.Binding - Esc key.Binding - Refresh key.Binding + Tab key.Binding + ShiftTab key.Binding + One key.Binding + Two key.Binding + Three key.Binding + Four key.Binding + Five key.Binding + Six key.Binding + Seven key.Binding + Visualize key.Binding + Metric key.Binding + Sort key.Binding + ReverseSort key.Binding + DirGroup key.Binding + SelectPID key.Binding + SelectTID key.Binding + Probes key.Binding + Filter key.Binding + FilterUndo key.Binding + Export key.Binding + Quit key.Binding + Enter key.Binding + Esc key.Binding + Refresh key.Binding } // Keys contains the default shared key map. @@ -41,29 +42,30 @@ var Keys = DefaultKeyMap() // DefaultKeyMap builds the default key bindings used by models. func DefaultKeyMap() KeyMap { return KeyMap{ - Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), - ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), - One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), - Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), - Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), - Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), - Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), - Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), - Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), - Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), - Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), - Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort table")), - DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), - SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), - SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), - Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), - Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), - FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")), - Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "stream export")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), + One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), + Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), + Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), + Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), + Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), + Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), + Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), + Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort table")), + ReverseSort: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "reverse sort")), + DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), + SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), + SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), + Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), + FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")), + Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "stream export")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), } } @@ -97,6 +99,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, k.Sort, + k.ReverseSort, k.Filter, k.FilterUndo, k.SelectPID, @@ -113,6 +116,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, k.Sort, + k.ReverseSort, helpTextBinding("space", "stream pause"), helpTextBinding("enter", "selected filter"), helpTextBinding("esc", "stream undo filter"), @@ -142,7 +146,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { controls = append(controls, k.Export) } controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit) - controls = append(controls, k.Visualize, k.Metric, k.Sort, k.Filter, k.FilterUndo) + controls = append(controls, k.Visualize, k.Metric, k.Sort, k.ReverseSort, k.Filter, k.FilterUndo) return [][]key.Binding{ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven}, diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go index 8d34285..2c48603 100644 --- a/internal/tui/common/keys_test.go +++ b/internal/tui/common/keys_test.go @@ -43,6 +43,10 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) { if sortHelp.Key != "s" || sortHelp.Desc != "sort table" { t.Fatalf("unexpected sort binding help: key=%q desc=%q", sortHelp.Key, sortHelp.Desc) } + reverseSortHelp := keys.ReverseSort.Help() + if reverseSortHelp.Key != "S" || reverseSortHelp.Desc != "reverse sort" { + t.Fatalf("unexpected reverse sort binding help: key=%q desc=%q", reverseSortHelp.Key, reverseSortHelp.Desc) + } undoHelp := keys.FilterUndo.Help() if undoHelp.Key != "F" || undoHelp.Desc != "undo filter" { @@ -156,6 +160,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { found = false for _, binding := range groups[1] { help := binding.Help() + if help.Key == "S" && help.Desc == "reverse sort" { + found = true + break + } + } + if !found { + t.Fatalf("expected reverse sort binding in dashboard full help controls") + } + + found = false + for _, binding := range groups[1] { + help := binding.Help() if help.Key == "F" && help.Desc == "undo filter" { found = true break @@ -174,6 +190,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { foundOne := false foundUndo := false foundSort := false + foundReverseSort := false for _, binding := range short { help := binding.Help() if help.Key == "o" && help.Desc == "probes" { @@ -191,6 +208,9 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if help.Key == "s" && help.Desc == "sort table" { foundSort = true } + if help.Key == "S" && help.Desc == "reverse sort" { + foundReverseSort = true + } } if !found { t.Fatalf("expected probes binding in dashboard short help") @@ -207,4 +227,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if !foundSort { t.Fatalf("expected sort binding in dashboard short help") } + if !foundReverseSort { + t.Fatalf("expected reverse sort binding in dashboard short help") + } } diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index 25a7ea5..f24c87c 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -72,7 +72,7 @@ func renderFilesWithSort(snap *statsengine.Snapshot, width, height, offset, sele offset, selectedCol, "enter:filter", - "s:sort", + "s/S:sort", fileSortHint(sortState), "d:dirs", "v:mode in dirs", @@ -102,7 +102,7 @@ func renderFilesDirGroupedWithSort(snap *statsengine.Snapshot, width, height, of offset, selectedCol, "enter:filter", - "s:sort", + "s/S:sort", fileDirSortHint(sortState), "d:files", "v:mode", @@ -185,10 +185,11 @@ func sortedFileSnapshots(rows []statsengine.FileSnapshot, sortState tableSortSta sorted := slices.Clone(rows) slices.SortFunc(sorted, func(left, right statsengine.FileSnapshot) int { - if cmp := compareFileBySort(left, right, sortState.key); cmp != 0 { - return cmp + cmp := compareFileBySort(left, right, sortState.key) + if cmp == 0 { + cmp = compareFileDefault(left, right) } - return compareFileDefault(left, right) + return sortState.apply(cmp) }) return sorted } @@ -203,10 +204,11 @@ func sortedDirSnapshots(rows []DirSnapshot, sortState tableSortState[fileDirSort sorted := slices.Clone(rows) slices.SortFunc(sorted, func(left, right DirSnapshot) int { - if cmp := compareDirBySort(left, right, sortState.key); cmp != 0 { - return cmp + cmp := compareDirBySort(left, right, sortState.key) + if cmp == 0 { + cmp = compareDirDefault(left, right) } - return compareDirDefault(left, right) + return sortState.apply(cmp) }) return sorted } @@ -315,17 +317,17 @@ func fileSortLabel(sortState tableSortState[fileSortKey]) string { } switch sortState.key { case fileSortKeyAccesses: - return "Accesses desc" + return sortLabelWithDirection("Accesses", false, sortState.reverse) case fileSortKeyRead: - return "Read desc" + return sortLabelWithDirection("Read", false, sortState.reverse) case fileSortKeyWrite: - return "Write desc" + return sortLabelWithDirection("Write", false, sortState.reverse) case fileSortKeyAvgLatency: - return "Avg Latency desc" + return sortLabelWithDirection("Avg Latency", false, sortState.reverse) case fileSortKeyMaxLatency: - return "Max Latency desc" + return sortLabelWithDirection("Max Latency", false, sortState.reverse) case fileSortKeyPath: - return "Path asc" + return sortLabelWithDirection("Path", true, sortState.reverse) default: return "default" } @@ -341,19 +343,19 @@ func fileDirSortLabel(sortState tableSortState[fileDirSortKey]) string { } switch sortState.key { case fileDirSortKeyAccesses: - return "Accesses desc" + return sortLabelWithDirection("Accesses", false, sortState.reverse) case fileDirSortKeyRead: - return "Read desc" + return sortLabelWithDirection("Read", false, sortState.reverse) case fileDirSortKeyWrite: - return "Write desc" + return sortLabelWithDirection("Write", false, sortState.reverse) case fileDirSortKeyAvgLatency: - return "Avg Latency desc" + return sortLabelWithDirection("Avg Latency", false, sortState.reverse) case fileDirSortKeyMaxLatency: - return "Max Latency desc" + return sortLabelWithDirection("Max Latency", false, sortState.reverse) case fileDirSortKeyFileCount: - return "Files desc" + return sortLabelWithDirection("Files", false, sortState.reverse) case fileDirSortKeyDir: - return "Directory asc" + return sortLabelWithDirection("Directory", true, sortState.reverse) default: return "default" } diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go index 87ada56..480c25f 100644 --- a/internal/tui/dashboard/files_test.go +++ b/internal/tui/dashboard/files_test.go @@ -27,7 +27,7 @@ func TestRenderFilesIncludesHeaders(t *testing.T) { t.Fatalf("expected token %q in files table output", token) } } - if !strings.Contains(out, "s:sort") { + if !strings.Contains(out, "s/S:sort") { t.Fatalf("expected files sort hint in output") } if !strings.Contains(out, "sort: default") { @@ -128,6 +128,11 @@ func TestSortedFileSnapshotsUsesSelectedSortKey(t *testing.T) { if sorted[0].Path != "/tmp/a.log" { t.Fatalf("expected read desc sort to put /tmp/a.log first, got %q", sorted[0].Path) } + + sorted = sortedFileSnapshots(rows, tableSortState[fileSortKey]{active: true, key: fileSortKeyPath, reverse: true}) + if sorted[0].Path != "/tmp/z.log" { + t.Fatalf("expected reverse path sort to put /tmp/z.log first, got %q", sorted[0].Path) + } } func TestSortedDirSnapshotsUsesSelectedSortKey(t *testing.T) { @@ -145,4 +150,9 @@ func TestSortedDirSnapshotsUsesSelectedSortKey(t *testing.T) { if sorted[0].Dir != "/tmp" { t.Fatalf("expected file-count sort to put /tmp first, got %q", sorted[0].Dir) } + + sorted = sortedDirSnapshots(rows, tableSortState[fileDirSortKey]{active: true, key: fileDirSortKeyDir, reverse: true}) + if sorted[0].Dir != "/var/log" { + t.Fatalf("expected reverse dir sort to put /var/log first, got %q", sorted[0].Dir) + } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index a35e473..526ea1d 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -377,22 +377,23 @@ func (m Model) selectedSyscallName() string { } func (m *Model) handleSortKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { - if !key.Matches(msg, m.keys.Sort) { + reverse := key.Matches(msg, m.keys.ReverseSort) + if !reverse && !key.Matches(msg, m.keys.Sort) { return false, nil } switch m.activeTab { case TabSyscalls: - return m.handleSyscallsSortKey() + return m.handleSyscallsSortKey(reverse) case TabFiles: - return m.handleFilesSortKey() + return m.handleFilesSortKey(reverse) case TabProcesses: - return m.handleProcessesSortKey() + return m.handleProcessesSortKey(reverse) default: return false, nil } } -func (m *Model) handleSyscallsSortKey() (bool, tea.Cmd) { +func (m *Model) handleSyscallsSortKey(reverse bool) (bool, tea.Cmd) { if m.syscallsVizMode != tabVizModeTable { return false, nil } @@ -401,12 +402,12 @@ func (m *Model) handleSyscallsSortKey() (bool, tea.Cmd) { return false, nil } selectedName := m.selectedSyscallName() - m.syscallsSort = m.syscallsSort.toggled(key) + m.syscallsSort = m.syscallsSort.toggled(key, reverse) m.reanchorSyscallsOffset(selectedName) return true, nil } -func (m *Model) handleFilesSortKey() (bool, tea.Cmd) { +func (m *Model) handleFilesSortKey(reverse bool) (bool, tea.Cmd) { if m.filesVizMode != tabVizModeTable { return false, nil } @@ -416,7 +417,7 @@ func (m *Model) handleFilesSortKey() (bool, tea.Cmd) { return false, nil } selectedDir := m.selectedDirPath() - m.filesDirSort = m.filesDirSort.toggled(key) + m.filesDirSort = m.filesDirSort.toggled(key, reverse) m.reanchorFilesDirOffset(selectedDir) return true, nil } @@ -425,12 +426,12 @@ func (m *Model) handleFilesSortKey() (bool, tea.Cmd) { return false, nil } selectedPath := m.selectedFilePath() - m.filesSort = m.filesSort.toggled(key) + m.filesSort = m.filesSort.toggled(key, reverse) m.reanchorFilesOffset(selectedPath) return true, nil } -func (m *Model) handleProcessesSortKey() (bool, tea.Cmd) { +func (m *Model) handleProcessesSortKey(reverse bool) (bool, tea.Cmd) { if m.processesVizMode != tabVizModeTable { return false, nil } @@ -439,7 +440,7 @@ func (m *Model) handleProcessesSortKey() (bool, tea.Cmd) { return false, nil } selectedPID := m.selectedProcessPID() - m.processesSort = m.processesSort.toggled(key) + m.processesSort = m.processesSort.toggled(key, reverse) m.reanchorProcessesOffset(selectedPID) return true, nil } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 576f06f..c2dfbba 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -294,6 +294,29 @@ func TestProcessesSortKeyTogglesOnSelectedColumn(t *testing.T) { } } +func TestProcessesReverseSortKeyTogglesOnSelectedColumn(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 200, Comm: "worker", Syscalls: 9}, + {PID: 100, Comm: "agent", Syscalls: 3}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.processesCol = 1 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model := next.(Model) + if !model.processesSort.active || model.processesSort.key != processSortKeyComm || !model.processesSort.reverse { + t.Fatalf("expected reverse process comm sort enabled, got %+v", model.processesSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model = next.(Model) + if model.processesSort.active { + t.Fatalf("expected second S press to restore default process ordering") + } +} + func TestProcessesSortEnterUsesSortedVisibleRow(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabProcesses @@ -435,6 +458,28 @@ func TestSyscallsSortKeyTogglesOnSelectedColumn(t *testing.T) { } } +func TestSyscallsReverseSortKeyTogglesOnSelectedColumn(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ + {Name: "write", Count: 9}, + {Name: "read", Count: 3}, + }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model := next.(Model) + if !model.syscallsSort.active || model.syscallsSort.key != syscallSortKeyName || !model.syscallsSort.reverse { + t.Fatalf("expected reverse syscall name sort enabled, got %+v", model.syscallsSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model = next.(Model) + if model.syscallsSort.active { + t.Fatalf("expected second S press to restore default ordering") + } +} + func TestSyscallsSortReanchorsSelectedSyscall(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls @@ -621,6 +666,53 @@ func TestFilesSortKeyTogglesFlatMode(t *testing.T) { } } +func TestFilesReverseSortKeyTogglesFlatMode(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: "S"}) + model := next.(Model) + if !model.filesSort.active || model.filesSort.key != fileSortKeyPath || !model.filesSort.reverse { + t.Fatalf("expected reverse flat file path sort enabled, got %+v", model.filesSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model = next.(Model) + if model.filesSort.active { + t.Fatalf("expected second S press to restore default file ordering") + } +} + +func TestFilesDirReverseSortKeyTogglesGroupedMode(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.filesDirCol = 6 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model := next.(Model) + if !model.filesDirSort.active || model.filesDirSort.key != fileDirSortKeyDir || !model.filesDirSort.reverse { + t.Fatalf("expected reverse grouped file dir sort enabled, got %+v", model.filesDirSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'S'}[0], Text: "S"}) + model = next.(Model) + if model.filesDirSort.active { + t.Fatalf("expected second S press to restore default grouped file ordering") + } +} + func TestFilesSortEnterUsesSortedVisibleRow(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index aae30f8..34fdbc8 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -40,7 +40,7 @@ func renderProcessesWithSort(snap *statsengine.Snapshot, width, height, offset, } columns := processColumns() - out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "s:sort", processSortHint(sortState), "v:mode", "b:metric") + out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "s/S:sort", processSortHint(sortState), "v:mode", "b:metric") if pidFilter > 0 { out += "\n" + "Note: this tab is most useful with All PIDs." } @@ -68,10 +68,11 @@ func sortedProcessTableRows(rows []statsengine.ProcessSnapshot, sortState tableS sorted := slices.Clone(rows) slices.SortFunc(sorted, func(left, right statsengine.ProcessSnapshot) int { - if cmp := compareProcessBySort(left, right, sortState.key); cmp != 0 { - return cmp + cmp := compareProcessBySort(left, right, sortState.key) + if cmp == 0 { + cmp = compareProcessDefault(left, right) } - return compareProcessDefault(left, right) + return sortState.apply(cmp) }) return sorted } @@ -134,17 +135,17 @@ func processSortLabel(sortState tableSortState[processSortKey]) string { } switch sortState.key { case processSortKeyPID: - return "PID asc" + return sortLabelWithDirection("PID", true, sortState.reverse) case processSortKeyComm: - return "Comm asc" + return sortLabelWithDirection("Comm", true, sortState.reverse) case processSortKeySyscalls: - return "Syscalls desc" + return sortLabelWithDirection("Syscalls", false, sortState.reverse) case processSortKeyRate: - return "Rate/s desc" + return sortLabelWithDirection("Rate/s", false, sortState.reverse) case processSortKeyBytes: - return "Total Bytes desc" + return sortLabelWithDirection("Total Bytes", false, sortState.reverse) case processSortKeyAvgLatency: - return "Avg Latency desc" + return sortLabelWithDirection("Avg Latency", false, sortState.reverse) default: return "default" } diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go index 7c04497..d2cd412 100644 --- a/internal/tui/dashboard/processes_test.go +++ b/internal/tui/dashboard/processes_test.go @@ -28,7 +28,7 @@ func TestRenderProcessesIncludesHeaders(t *testing.T) { if !strings.Contains(out, "100") || !strings.Contains(out, "proc-a") { t.Fatalf("expected process row in output") } - if !strings.Contains(out, "s:sort") { + if !strings.Contains(out, "s/S:sort") { t.Fatalf("expected processes sort hint in output") } if !strings.Contains(out, "sort: default") { @@ -75,4 +75,9 @@ func TestSortedProcessTableRowsUsesSelectedSortKey(t *testing.T) { if sorted[0].PID != 100 { t.Fatalf("expected pid asc sort to put PID 100 first, got %d", sorted[0].PID) } + + sorted = sortedProcessTableRows(rows, tableSortState[processSortKey]{active: true, key: processSortKeyComm, reverse: true}) + if sorted[0].PID != 200 { + t.Fatalf("expected reverse comm sort to put PID 200 first, got %d", sorted[0].PID) + } } diff --git a/internal/tui/dashboard/sort.go b/internal/tui/dashboard/sort.go index 7d985a6..399d0c5 100644 --- a/internal/tui/dashboard/sort.go +++ b/internal/tui/dashboard/sort.go @@ -1,15 +1,34 @@ package dashboard type tableSortState[K comparable] struct { - active bool - key K + active bool + key K + reverse bool } -func (s tableSortState[K]) toggled(key K) tableSortState[K] { - if s.active && s.key == key { +func (s tableSortState[K]) toggled(key K, reverse bool) tableSortState[K] { + if s.active && s.key == key && s.reverse == reverse { return tableSortState[K]{} } - return tableSortState[K]{active: true, key: key} + return tableSortState[K]{active: true, key: key, reverse: reverse} +} + +func (s tableSortState[K]) apply(cmp int) int { + if !s.reverse { + return cmp + } + return -cmp +} + +func sortDirectionLabel(defaultAscending, reverse bool) string { + if defaultAscending != reverse { + return "asc" + } + return "desc" +} + +func sortLabelWithDirection(name string, defaultAscending, reverse bool) string { + return name + " " + sortDirectionLabel(defaultAscending, reverse) } func compareUint64Desc(left, right uint64) int { diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 8f2530a..3b64b12 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -51,7 +51,7 @@ func renderSyscallsWithSort(snap *statsengine.Snapshot, width, height, offset, s offset, selectedCol, "enter:filter", - "s:sort", + "s/S:sort", syscallSortHint(sortState), "v:mode", "b:metric", @@ -105,10 +105,11 @@ func sortedSyscallSnapshots(rows []statsengine.SyscallSnapshot, sortState tableS sorted := slices.Clone(rows) slices.SortFunc(sorted, func(left, right statsengine.SyscallSnapshot) int { - if cmp := compareSyscallBySort(left, right, sortState.key); cmp != 0 { - return cmp + cmp := compareSyscallBySort(left, right, sortState.key) + if cmp == 0 { + cmp = compareSyscallDefault(left, right) } - return compareSyscallDefault(left, right) + return sortState.apply(cmp) }) return sorted } @@ -218,27 +219,27 @@ func syscallSortLabel(sortState tableSortState[syscallSortKey]) string { } switch sortState.key { case syscallSortKeyName: - return "Syscall asc" + return sortLabelWithDirection("Syscall", true, sortState.reverse) case syscallSortKeyCount: - return "Count desc" + return sortLabelWithDirection("Count", false, sortState.reverse) case syscallSortKeyRate: - return "Rate/s desc" + return sortLabelWithDirection("Rate/s", false, sortState.reverse) case syscallSortKeyAvg: - return "Avg desc" + return sortLabelWithDirection("Avg", false, sortState.reverse) case syscallSortKeyMin: - return "Min desc" + return sortLabelWithDirection("Min", false, sortState.reverse) case syscallSortKeyMax: - return "Max desc" + return sortLabelWithDirection("Max", false, sortState.reverse) case syscallSortKeyP50: - return "p50 desc" + return sortLabelWithDirection("p50", false, sortState.reverse) case syscallSortKeyP95: - return "p95 desc" + return sortLabelWithDirection("p95", false, sortState.reverse) case syscallSortKeyP99: - return "p99 desc" + return sortLabelWithDirection("p99", false, sortState.reverse) case syscallSortKeyBytes: - return "Bytes desc" + return sortLabelWithDirection("Bytes", false, sortState.reverse) case syscallSortKeyErrors: - return "Errors desc" + return sortLabelWithDirection("Errors", false, sortState.reverse) default: return "default" } diff --git a/internal/tui/dashboard/syscalls_test.go b/internal/tui/dashboard/syscalls_test.go index 5645aae..2096608 100644 --- a/internal/tui/dashboard/syscalls_test.go +++ b/internal/tui/dashboard/syscalls_test.go @@ -28,7 +28,7 @@ func TestRenderSyscallsIncludesHeaders(t *testing.T) { if !strings.Contains(out, "read") { t.Fatalf("expected syscall row in output") } - if !strings.Contains(out, "s:sort") { + if !strings.Contains(out, "s/S:sort") { t.Fatalf("expected syscall sort hint in output") } if !strings.Contains(out, "sort: default") { @@ -72,6 +72,11 @@ func TestSortedSyscallSnapshotsUsesSelectedSortKey(t *testing.T) { if sorted[0].Name != "read" { t.Fatalf("expected p95 desc sort to put read first, got %q", sorted[0].Name) } + + sorted = sortedSyscallSnapshots(rows, tableSortState[syscallSortKey]{active: true, key: syscallSortKeyName, reverse: true}) + if sorted[0].Name != "write" { + t.Fatalf("expected reverse syscall name sort to put write first, got %q", sorted[0].Name) + } } func TestSyscallSortKeyForColumnKeepsLogicalMeaningAcrossWidths(t *testing.T) { diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 498d606..cbf1810 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -75,4 +75,7 @@ func TestRenderHelpBarIncludesSortBinding(t *testing.T) { if !strings.Contains(out, "s sort table") { t.Fatalf("expected sort binding in rendered help bar, got %q", out) } + if !strings.Contains(out, "S reverse sort") { + t.Fatalf("expected reverse sort binding in rendered help bar, got %q", out) + } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index af44b11..22dbf37 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1223,6 +1223,7 @@ func (m Model) helpSections() []helpSection { lines: []string{ "tab/shift+tab tabs 1..7 jump tab r reset baseline", "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom", + "sys/files/proc tables: s sort S reverse sort", "sys/proc: v bubbles b metric events/bytes", "files: d dirs toggle v bubbles (dirs only) b metric", "flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order", |
