summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 18:33:44 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 18:33:44 +0200
commita76e81adca48fea5df4a16382ec7e7b0ab461e7f (patch)
tree633e239adddceb7ef1ca5fc3c1301fe4e1206716
parentb3bbf184dcdff908abbd4413c77e1455b24de0c9 (diff)
feat(tui): add files icicle visualization mode (task 383)
-rw-r--r--internal/tui/dashboard/icicle.go292
-rw-r--r--internal/tui/dashboard/model.go8
-rw-r--r--internal/tui/dashboard/model_test.go42
3 files changed, 340 insertions, 2 deletions
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go
new file mode 100644
index 0000000..d868ae3
--- /dev/null
+++ b/internal/tui/dashboard/icicle.go
@@ -0,0 +1,292 @@
+package dashboard
+
+import (
+ "fmt"
+ "math"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "ior/internal/statsengine"
+)
+
+type icicleNode struct {
+ name string
+ fullPath string
+ accesses uint64
+ bytes uint64
+ children map[string]*icicleNode
+}
+
+type icicleTile struct {
+ node *icicleNode
+ depth int
+ x int
+ w int
+ colorSlot int
+}
+
+func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string {
+ if snap == nil {
+ return "Files icicle: waiting for stats..."
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 18
+ }
+ header := fmt.Sprintf("Files icicle | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric))
+ dirs := aggregateFilesByDir(snap.Files())
+ if len(dirs) == 0 {
+ return header + "\nFiles icicle: no directory data\nsel: none"
+ }
+
+ root := buildIcicleTree(dirs)
+ children := sortedIcicleChildren(root, metric)
+ if len(children) == 0 {
+ return header + "\nFiles icicle: no directory data\nsel: none"
+ }
+
+ chartHeight := height - 2
+ if chartHeight < 4 {
+ chartHeight = 4
+ }
+
+ tiles := make([]icicleTile, 0, 64)
+ layoutIcicle(children, 0, width, 0, chartHeight, 0, metric, &tiles)
+ if len(tiles) == 0 {
+ return header + "\nFiles icicle: no visible tiles\nsel: none"
+ }
+
+ selected = clampOffset(selected, len(tiles))
+ grid := make([][]treemapCell, chartHeight)
+ for row := 0; row < chartHeight; row++ {
+ grid[row] = make([]treemapCell, width)
+ for col := range grid[row] {
+ grid[row][col] = treemapCell{char: ' ', colorSlot: -1}
+ }
+ }
+ fillIcicleGrid(grid, tiles, selected)
+ palette := treemapPalette(isDark)
+
+ lines := make([]string, 0, chartHeight+2)
+ lines = append(lines, padOrTrim(header, width))
+ for _, row := range grid {
+ lines = append(lines, renderTreemapRow(row, palette))
+ }
+ lines = append(lines, padOrTrim(icicleStatusLine(tiles, selected, metric), width))
+ return strings.Join(lines, "\n")
+}
+
+func buildIcicleTree(dirs []DirSnapshot) *icicleNode {
+ root := &icicleNode{
+ name: "/",
+ fullPath: "/",
+ children: make(map[string]*icicleNode),
+ }
+ for _, dir := range dirs {
+ segments := splitIcicleSegments(dir.Dir)
+ current := root
+ metricBytes := dir.BytesRead + dir.BytesWritten
+ current.accesses += dir.Accesses
+ current.bytes += metricBytes
+ currentPath := "/"
+ for _, segment := range segments {
+ if segment == "" {
+ continue
+ }
+ if currentPath == "/" {
+ currentPath = "/" + segment
+ } else {
+ currentPath = currentPath + "/" + segment
+ }
+ child := current.children[segment]
+ if child == nil {
+ child = &icicleNode{
+ name: segment,
+ fullPath: currentPath,
+ children: make(map[string]*icicleNode),
+ }
+ current.children[segment] = child
+ }
+ child.accesses += dir.Accesses
+ child.bytes += metricBytes
+ current = child
+ }
+ }
+ return root
+}
+
+func splitIcicleSegments(dir string) []string {
+ cleaned := filepath.Clean(strings.TrimSpace(dir))
+ if cleaned == "." || cleaned == "/" || cleaned == "" {
+ return nil
+ }
+ cleaned = strings.TrimPrefix(cleaned, "/")
+ if cleaned == "" {
+ return nil
+ }
+ return strings.Split(cleaned, "/")
+}
+
+func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode {
+ if node == nil || len(node.children) == 0 {
+ return nil
+ }
+ out := make([]*icicleNode, 0, len(node.children))
+ for _, child := range node.children {
+ out = append(out, child)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ vi := icicleValue(out[i], metric)
+ vj := icicleValue(out[j], metric)
+ if vi != vj {
+ return vi > vj
+ }
+ return out[i].name < out[j].name
+ })
+ return out
+}
+
+func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, metric bubbleMetric, out *[]icicleTile) {
+ if len(nodes) == 0 || width <= 0 || depth >= maxDepth {
+ return
+ }
+ total := uint64(0)
+ for _, node := range nodes {
+ total += icicleValue(node, metric)
+ }
+ if total == 0 {
+ return
+ }
+
+ remainingWidth := width
+ remainingValue := total
+ cursor := x
+ for idx, node := range nodes {
+ value := icicleValue(node, metric)
+ tileWidth := remainingWidth
+ if idx < len(nodes)-1 {
+ tileWidth = int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue)))
+ minRemaining := len(nodes) - idx - 1
+ if tileWidth < 1 {
+ tileWidth = 1
+ }
+ if tileWidth > remainingWidth-minRemaining {
+ tileWidth = remainingWidth - minRemaining
+ }
+ }
+ if tileWidth <= 0 {
+ continue
+ }
+ colorSlot := rootSlot
+ if depth == 0 {
+ colorSlot = idx
+ }
+ *out = append(*out, icicleTile{
+ node: node,
+ depth: depth,
+ x: cursor,
+ w: tileWidth,
+ colorSlot: colorSlot,
+ })
+ if depth+1 < maxDepth {
+ layoutIcicle(sortedIcicleChildren(node, metric), cursor, tileWidth, depth+1, maxDepth, colorSlot, metric, out)
+ }
+
+ cursor += tileWidth
+ remainingWidth -= tileWidth
+ remainingValue -= value
+ if remainingWidth <= 0 {
+ break
+ }
+ }
+}
+
+func fillIcicleGrid(grid [][]treemapCell, tiles []icicleTile, selected int) {
+ height := len(grid)
+ if height == 0 {
+ return
+ }
+ width := len(grid[0])
+ if width == 0 {
+ return
+ }
+ for idx, tile := range tiles {
+ if tile.depth < 0 || tile.depth >= height {
+ continue
+ }
+ isSelected := idx == selected
+ for col := tile.x; col < minInt(width, tile.x+tile.w); col++ {
+ if col < 0 {
+ continue
+ }
+ grid[tile.depth][col] = treemapCell{
+ char: '█',
+ colorSlot: tile.colorSlot,
+ bold: isSelected,
+ }
+ }
+ drawIcicleLabel(grid, tile, isSelected)
+ }
+}
+
+func drawIcicleLabel(grid [][]treemapCell, tile icicleTile, selected bool) {
+ height := len(grid)
+ if height == 0 || tile.depth < 0 || tile.depth >= height || tile.w <= 1 {
+ return
+ }
+ width := len(grid[0])
+ maxLabel := tile.w - 1
+ label := abbreviateTreemapLabel(tile.node.name, maxLabel)
+ col := tile.x
+ for _, r := range []rune(label) {
+ if col < 0 {
+ col++
+ continue
+ }
+ if col >= width {
+ break
+ }
+ grid[tile.depth][col] = treemapCell{
+ char: r,
+ colorSlot: tile.colorSlot,
+ bold: selected,
+ }
+ col++
+ }
+}
+
+func icicleStatusLine(tiles []icicleTile, selected int, metric bubbleMetric) string {
+ if len(tiles) == 0 {
+ return "sel:none"
+ }
+ selected = clampOffset(selected, len(tiles))
+ tile := tiles[selected]
+ metricValue := icicleValue(tile.node, metric)
+ metricText := fmt.Sprintf("%d", metricValue)
+ if metric == bubbleMetricBytes {
+ metricText = formatBytes(float64(metricValue))
+ }
+ return fmt.Sprintf(
+ "sel:%d/%d %s | %s=%s | accesses=%d | bytes=%s",
+ selected+1,
+ len(tiles),
+ tile.node.fullPath,
+ treemapMetricLabel(metric),
+ metricText,
+ tile.node.accesses,
+ formatBytes(float64(tile.node.bytes)),
+ )
+}
+
+func icicleValue(node *icicleNode, metric bubbleMetric) uint64 {
+ if node == nil {
+ return 0
+ }
+ if metric == bubbleMetricBytes {
+ return node.bytes
+ }
+ return node.accesses
+}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index c0bcc11..08272b9 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -47,6 +47,7 @@ const (
tabVizModeTable tabVizMode = iota
tabVizModeBubbles
tabVizModeTreemap
+ tabVizModeIcicle
)
// Model is the dashboard tab framework model.
@@ -284,7 +285,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.DirGroup):
if m.activeTab == TabFiles {
m.filesDirGrouped = !m.filesDirGrouped
- if !m.filesDirGrouped && m.filesVizMode == tabVizModeBubbles {
+ if !m.filesDirGrouped && m.filesVizMode != tabVizModeTable {
m.filesVizMode = tabVizModeTable
}
if m.bubbleEnabledForTab(m.activeTab) && m.refreshBubbleData() {
@@ -522,6 +523,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
if m.activeTab == TabSyscalls && m.syscallsVizMode == tabVizModeTreemap {
return renderSyscallsTreemap(m.latest, width, activeHeight, m.syscallsChart.Metric(), m.syscallsTreemapSelection, m.isDark)
}
+ if m.activeTab == TabFiles && m.filesVizMode == tabVizModeIcicle && m.filesDirGrouped {
+ return renderFilesIcicle(m.latest, width, activeHeight, m.filesChart.Metric(), m.filesDirOffset, m.isDark)
+ }
if m.bubbleEnabledForTab(m.activeTab) {
switch m.activeTab {
case TabSyscalls:
@@ -726,7 +730,7 @@ func (m Model) allowedVizModes(tab Tab) []tabVizMode {
return []tabVizMode{tabVizModeTable, tabVizModeBubbles}
case TabFiles:
if m.filesDirGrouped {
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles}
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeIcicle}
}
return []tabVizMode{tabVizModeTable}
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index a92a77c..522e97e 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -385,6 +385,23 @@ func TestBubbleMetricToggleForSyscallsTab(t *testing.T) {
}
}
+func TestMetricToggleAppliesInFilesIcicleMode(t *testing.T) {
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
+ {Path: "/var/log/a", Accesses: 5, BytesRead: 120, BytesWritten: 40},
+ }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ m.latest = &snap
+ m.filesDirGrouped = true
+ m.filesVizMode = tabVizModeIcicle
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'b'}[0], Text: string([]rune{'b'})})
+ model := next.(Model)
+ if got := model.filesChart.Metric(); got != bubbleMetricBytes {
+ t.Fatalf("expected files metric toggle to bytes in icicle mode, got %q", got)
+ }
+}
+
func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
{Path: "/tmp/a", Accesses: 3},
@@ -412,6 +429,12 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
t.Fatalf("expected files bubble mode enabled in directory mode")
}
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
+ model = next.(Model)
+ if got := model.filesVizMode; got != tabVizModeIcicle {
+ t.Fatalf("expected files icicle mode enabled in directory mode")
+ }
+
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
model = next.(Model)
if got := model.filesVizMode; got != tabVizModeTable {
@@ -475,6 +498,25 @@ func TestTreemapModeRendersTreemapHeader(t *testing.T) {
}
}
+func TestIcicleModeRendersFilesHeader(t *testing.T) {
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
+ {Path: "/srv/log/a", Accesses: 9, BytesRead: 400, BytesWritten: 200},
+ {Path: "/srv/log/b", Accesses: 4, BytesRead: 100, BytesWritten: 40},
+ }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ m.latest = &snap
+ m.filesDirGrouped = true
+ m.filesVizMode = tabVizModeIcicle
+ m.width = 120
+ m.height = 28
+
+ out := m.View().Content
+ if !strings.Contains(out, "Files icicle") {
+ t.Fatalf("expected icicle header in files view")
+ }
+}
+
func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabSyscalls