From 64cad664dd02b9230366867e61149bcf421dc064 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Tue, 4 Aug 2026 19:57:46 +0530 Subject: [PATCH 1/3] perf(tui): replace allocating match-scan loop in viewer search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inner loop in renderViewerLine converted rune slices to strings on every candidate position to find search matches, producing O(N·W) temporary allocations per visible line (N lines × W candidate positions). Replaced with strings.Index on the lowercased tail of the line, which finds each match in O(N) without per-position allocation. The byte offset returned by strings.Index is converted back to a rune offset once per confirmed match via utf8.RuneCountInString. --- CHANGELOG.md | 8 ++++++++ tui/fileview.go | 17 +++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6430254..e1bea27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tui/fileview.go b/tui/fileview.go index 4be0f35..3657275 100644 --- a/tui/fileview.go +++ b/tui/fileview.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "unicode/utf8" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -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 { From d6b894ed99a654c9d651f25179f6d0a34336168c Mon Sep 17 00:00:00 2001 From: deveshctl Date: Tue, 4 Aug 2026 19:58:23 +0530 Subject: [PATCH 2/3] perf(image): precompute directory effective sizes once at tree build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodeEffectiveSize performed a full recursive subtree walk for every directory in the visible list on each sort invocation, making sort cost O(N²) in node count for large layers with deep directory trees. Added FileNode.EffectiveSize, populated by a single post-order DFS (computeEffectiveSizes) called once per tree after it is fully built in Stack and BuildAggregatedTrees. The TUI sort and size-column paths read the field directly, turning per-sort subtree walks into O(1) field reads. --- image/filetree.go | 34 ++++++++++++++++++ image/filetree_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++ image/stack.go | 6 ++++ tui/filetree.go | 8 ++--- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 image/filetree_test.go diff --git a/image/filetree.go b/image/filetree.go index 58f53f6..fd0d2b7 100644 --- a/image/filetree.go +++ b/image/filetree.go @@ -20,6 +20,11 @@ type FileNode struct { 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 @@ -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) +} diff --git a/image/filetree_test.go b/image/filetree_test.go new file mode 100644 index 0000000..f6776cf --- /dev/null +++ b/image/filetree_test.go @@ -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") + } +} diff --git a/image/stack.go b/image/stack.go index 18052b2..04ce817 100644 --- a/image/stack.go +++ b/image/stack.go @@ -16,6 +16,7 @@ func Stack(layers []Layer) []*FileTree { } else { stacked := cloneAsUnchanged(cumulative) result[i] = &FileTree{Root: stacked} + result[i].computeEffectiveSizes() } continue } @@ -23,10 +24,12 @@ func Stack(layers []Layer) []*FileTree { 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) } } @@ -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 diff --git a/tui/filetree.go b/tui/filetree.go index 1bfd37c..9a22732 100644 --- a/tui/filetree.go +++ b/tui/filetree.go @@ -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) } @@ -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 } From 33da3dfb17098bf64e35e23edbc6493f60ca0a6e Mon Sep 17 00:00:00 2001 From: deveshctl Date: Tue, 4 Aug 2026 20:12:31 +0530 Subject: [PATCH 3/3] style(image): align FileNode fields after EffectiveSize addition gofmt splits the struct's field-alignment block at the EffectiveSize doc comment; align the pre-comment fields to their own width so gofmt is clean. No behaviour change. --- image/filetree.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/image/filetree.go b/image/filetree.go index fd0d2b7..b47f97b 100644 --- a/image/filetree.go +++ b/image/filetree.go @@ -16,10 +16,10 @@ 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