Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
theme gradient are fixed for the session, so the per-character colour
interpolation was redundant work on each redraw. Output is unchanged; only the
repeated per-frame computation is removed.
- Directory sizes used by the sort-by-size view are now computed once when a
layer's file tree is built instead of re-walking each directory's subtree on
every sort invocation. Toggling sort (`s`) and navigating large layers with
deep directory trees is now constant-time rather than O(N²) in node count.
- Incremental search in the file viewer no longer allocates a temporary string
per candidate position when scanning each visible line for matches. The inner
scan now uses `strings.Index` on the lowercased line tail, reducing allocation
pressure during active search through large files.

## [v1.6.0] - 2026-07-28

Expand Down
42 changes: 38 additions & 4 deletions image/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ type FileTree struct {
}

type FileNode struct {
Name string
Path string
Linkname string
Size int64
Name string
Path string
Linkname string
Size int64
// EffectiveSize is the sum of Size for all non-removed file descendants
// (or Size itself for non-directory nodes). Populated once by
// computeEffectiveSizes after the tree is fully built; used by the TUI
// sort path to avoid an O(N²) subtree walk per sort invocation.
EffectiveSize int64
Mode fs.FileMode
UID int
GID int
Expand Down Expand Up @@ -71,3 +76,32 @@ func (t *FileTree) Walk(fn func(*FileNode)) {
walk(t.Root)
}
}

// computeEffectiveSizes populates EffectiveSize on every node in the tree via
// a single post-order DFS. For files it copies Size; for directories it sums
// the EffectiveSize of non-removed children. Called once per tree after it is
// fully built so the TUI sort path reads a field rather than re-walking the
// subtree on every sort invocation.
func (t *FileTree) computeEffectiveSizes() {
if t.Root == nil {
return
}
var walk func(*FileNode) int64
walk = func(n *FileNode) int64 {
if n.DiffType == Removed {
n.EffectiveSize = 0
return 0
}
if !n.IsDir {
n.EffectiveSize = n.Size
return n.Size
}
var total int64
for _, c := range n.Children {
total += walk(c)
}
n.EffectiveSize = total
return total
}
walk(t.Root)
}
79 changes: 79 additions & 0 deletions image/filetree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package image

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestComputeEffectiveSizes_File(t *testing.T) {
tree := makeTree(makeFile("a", "/a", 500))
tree.computeEffectiveSizes()
assert.Equal(t, int64(500), tree.Root.Children[0].EffectiveSize)
}

func TestComputeEffectiveSizes_Dir(t *testing.T) {
tree := makeTree(
makeDir("etc", "/etc",
makeFile("passwd", "/etc/passwd", 100),
makeFile("group", "/etc/group", 200),
),
)
tree.computeEffectiveSizes()
dir := tree.Root.Children[0]
assert.Equal(t, int64(300), dir.EffectiveSize)
assert.Equal(t, int64(100), dir.Children[0].EffectiveSize)
assert.Equal(t, int64(200), dir.Children[1].EffectiveSize)
}

func TestComputeEffectiveSizes_RemovedNodeIsZero(t *testing.T) {
f := makeFile("gone", "/gone", 1024)
f.DiffType = Removed
tree := makeTree(f)
tree.computeEffectiveSizes()
assert.Equal(t, int64(0), tree.Root.Children[0].EffectiveSize)
}

func TestComputeEffectiveSizes_DirExcludesRemovedChildren(t *testing.T) {
kept := makeFile("kept", "/etc/kept", 100)
removed := makeFile("gone", "/etc/gone", 900)
removed.DiffType = Removed
dir := makeDir("etc", "/etc", kept, removed)
tree := makeTree(dir)
tree.computeEffectiveSizes()
assert.Equal(t, int64(100), tree.Root.Children[0].EffectiveSize)
}

func TestComputeEffectiveSizes_RemovedDirIsZero(t *testing.T) {
child := makeFile("passwd", "/etc/passwd", 500)
dir := makeDir("etc", "/etc", child)
dir.DiffType = Removed
tree := makeTree(dir)
tree.computeEffectiveSizes()
assert.Equal(t, int64(0), tree.Root.Children[0].EffectiveSize)
}

func TestComputeEffectiveSizes_NilRoot(t *testing.T) {
tree := &FileTree{Root: nil}
// must not panic
tree.computeEffectiveSizes()
}

func TestComputeEffectiveSizes_MatchesNodeEffectiveSize(t *testing.T) {
// EffectiveSize after computeEffectiveSizes must agree with the
// reference nodeEffectiveSize used by the TUI sort tests.
added := makeFile("a", "/d/a", 10)
added.DiffType = Added
modified := makeFile("m", "/d/m", 20)
modified.DiffType = Modified
unchanged := makeFile("u", "/d/u", 30)
dir := makeDir("d", "/d", added, modified, unchanged)
tree := makeTree(dir)
tree.computeEffectiveSizes()

d := tree.Root.Children[0]
assert.Equal(t, int64(60), d.EffectiveSize)
for _, c := range d.Children {
assert.Equal(t, c.Size, c.EffectiveSize, "leaf node EffectiveSize must equal Size")
}
}
6 changes: 6 additions & 0 deletions image/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ func Stack(layers []Layer) []*FileTree {
} else {
stacked := cloneAsUnchanged(cumulative)
result[i] = &FileTree{Root: stacked}
result[i].computeEffectiveSizes()
}
continue
}

if cumulative == nil {
stacked := cloneAsAdded(layer.Tree.Root, i)
result[i] = &FileTree{Root: stacked}
result[i].computeEffectiveSizes()
cumulative = cloneStructure(stacked)
} else {
stacked := mergeLayer(cumulative, layer.Tree.Root, i)
result[i] = &FileTree{Root: stacked}
result[i].computeEffectiveSizes()
cumulative = cloneStructure(stacked)
}
}
Expand Down Expand Up @@ -343,15 +346,18 @@ func BuildAggregatedTrees(layers []Layer) []*FileTree {
baseline = cloneAsAdded(first.Tree.Root, 0)
}
result[0] = &FileTree{Root: cloneWithDiffType(baseline)}
result[0].computeEffectiveSizes()

for i := 1; i < len(layers); i++ {
layer := layers[i]
if layer.Tree == nil || layer.Tree.Root == nil || len(layer.Tree.Root.Children) == 0 {
result[i] = &FileTree{Root: cloneWithDiffType(baseline)}
result[i].computeEffectiveSizes()
continue
}
baseline = aggregateMerge(baseline, layer.Tree.Root, i)
result[i] = &FileTree{Root: cloneWithDiffType(baseline)}
result[i].computeEffectiveSizes()
}

return result
Expand Down
8 changes: 2 additions & 6 deletions tui/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ func formatSizeForNode(f *image.FileNode, flat bool) string {
return "0 B"
}
if flat {
sz := nodeEffectiveSize(f)
sz := f.EffectiveSize
if sz > 0 {
return image.FormatBytes(sz)
}
Expand Down Expand Up @@ -578,14 +578,10 @@ func applySortBySize(files []*image.FileNode, mode sortMode) []*image.FileNode {
if mode == sortNone {
return files
}
sizes := make(map[*image.FileNode]int64, len(files))
for _, f := range files {
sizes[f] = nodeEffectiveSize(f)
}
sorted := make([]*image.FileNode, len(files))
copy(sorted, files)
sort.Slice(sorted, func(i, j int) bool {
si, sj := sizes[sorted[i]], sizes[sorted[j]]
si, sj := sorted[i].EffectiveSize, sorted[j].EffectiveSize
if mode == sortDesc {
return si > sj
}
Expand Down
17 changes: 9 additions & 8 deletions tui/fileview.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"fmt"
"strings"
"unicode/utf8"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
Expand Down Expand Up @@ -239,20 +240,20 @@ func renderViewerLine(t Theme, line string, lineIdx int, query string, matches [
match bool
}

queryStr := string(queryRunes)
var segments []segment
pos := 0
occurrence := 0
for pos <= len(lowerLineRunes)-queryLen {
idx := -1
for i := pos; i <= len(lowerLineRunes)-queryLen; i++ {
if string(lowerLineRunes[i:i+queryLen]) == string(queryRunes) {
idx = i
break
}
}
if idx < 0 {
// Convert only the remaining rune slice to string for this iteration;
// strings.Index finds the match in O(N) without per-position allocation.
tail := string(lowerLineRunes[pos:])
byteIdx := strings.Index(tail, queryStr)
if byteIdx < 0 {
break
}
// Translate byte offset back to rune offset within tail.
idx := pos + utf8.RuneCountInString(tail[:byteIdx])
matchEnd := idx + queryLen

if idx > pos {
Expand Down
Loading