summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-09 23:01:38 +0200
committerPaul Buetow <paul@buetow.org>2026-03-09 23:01:38 +0200
commit227de0db390fec4e1327a7cab6be4c1268848695 (patch)
treef70ff9f3b23db47db0e0aeafa1bb1aad5abc71a8 /internal
parentbcaa22111ac619e317f7adfd60a1fc6bd4db8d29 (diff)
tui: add reverse sorting for dashboard tables (task 364)
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/common/keys.go98
-rw-r--r--internal/tui/common/keys_test.go23
-rw-r--r--internal/tui/dashboard/files.go44
-rw-r--r--internal/tui/dashboard/files_test.go12
-rw-r--r--internal/tui/dashboard/model.go23
-rw-r--r--internal/tui/dashboard/model_test.go92
-rw-r--r--internal/tui/dashboard/processes.go21
-rw-r--r--internal/tui/dashboard/processes_test.go7
-rw-r--r--internal/tui/dashboard/sort.go29
-rw-r--r--internal/tui/dashboard/syscalls.go31
-rw-r--r--internal/tui/dashboard/syscalls_test.go7
-rw-r--r--internal/tui/dashboard/tabs_test.go3
-rw-r--r--internal/tui/tui.go1
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",