From 11a0e401ee881754fda784eb8a2edc3f35478dd5 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 2 Oct 2026 08:40:10 +0100 Subject: [PATCH] Evaluate path predicates incrementally from cached directory paths --- PERFORMANCE.md | 6 +++--- ROADMAP.md | 2 +- internal/filter/apply.go | 9 ++++++++- internal/filter/eval.go | 26 +++++++++++++++++++++----- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/PERFORMANCE.md b/PERFORMANCE.md index e34a09a..20438c7 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -95,15 +95,15 @@ once per rendered frame, so a resize storm cannot queue up work. |---|---:| | `size > 1MB` | 31 ms | | `*.dat AND size > 500k` | 119 ms | -| `path contains sub3` (reconstructs every path) | 244 ms | +| `path contains sub3` | 112 ms | Filters run on a background goroutine with cancellation and a 120 ms keystroke debounce; the UI never waits for them. ## Known costs / future work -* Path predicates rebuild each node's path; an incremental evaluation along - the tree would make them ~10× cheaper. +* Path predicates now build each path from the cached parent directory path + (244 ms → 112 ms); globs are dominated by `filepath.Match`. * Node records could shrink (owner/group/mode/link count are only needed for display and some filters) if memory on 10 M+ entry trees matters. * Cold-cache HDD benchmarks still need to be run with root access. diff --git a/ROADMAP.md b/ROADMAP.md index 4fa3d32..5457a73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,7 +43,7 @@ Status markers: `[x]` done, `[~]` partial, `[ ]` not started. - [ ] Windows allocated-size via GetCompressedFileSizeW (opt-in) - [ ] configurable keybindings - [ ] cold-cache HDD scan benchmarks (need root to drop caches) -- [ ] incremental path-predicate evaluation for filters +- [x] incremental path-predicate evaluation for filters - [ ] permanent deletion (deliberately not offered yet; trash only) ## 1.0 diff --git a/internal/filter/apply.go b/internal/filter/apply.go index e405abe..3650325 100644 --- a/internal/filter/apply.go +++ b/internal/filter/apply.go @@ -43,13 +43,20 @@ func Apply(ctx context.Context, t *inventory.Tree, q *Query, m inventory.SizeMod r := &Result{Query: q, Mode: m, Sizes: make([]int64, n), Matched: make([]bool, n)} covered := make([]bool, n) c := &Ctx{Tree: t, Now: time.Now().UnixNano()} + if q.UsesPath { + c.dirPaths = map[inventory.NodeID]string{} + } for i := range n { if i&0xffff == 0 && ctx.Err() != nil { return nil, ctx.Err() } id := inventory.NodeID(i) nd := t.Node(id) - c.ID, c.Node = id, nd + c.ID, c.Node, c.path = id, nd, "" + if c.dirPaths != nil && nd.IsDir() { + c.path = c.lowerPath() + c.dirPaths[id] = c.path + } if i > 0 && q.Match(c) { r.Matched[i] = true covered[i] = true diff --git a/internal/filter/eval.go b/internal/filter/eval.go index e04cc94..cdda783 100644 --- a/internal/filter/eval.go +++ b/internal/filter/eval.go @@ -16,6 +16,9 @@ type Ctx struct { Node *inventory.Node Now int64 // unix nanoseconds path string + // dirPaths caches lower-cased directory paths during Apply, which visits + // parents before children, so a node's path is one concatenation. + dirPaths map[inventory.NodeID]string } func (c *Ctx) str(f Field) string { @@ -25,7 +28,7 @@ func (c *Ctx) str(f Field) string { return strings.ToLower(n.Name) case FPath: if c.path == "" { - c.path = strings.ToLower(c.Tree.Path(c.ID)) + c.path = c.lowerPath() } return c.path case FExt: @@ -62,6 +65,22 @@ func (c *Ctx) num(f Field) int64 { return 0 } +func (c *Ctx) lowerPath() string { + if c.dirPaths != nil { + if p, ok := c.dirPaths[c.ID]; ok { + return p + } + if pp, ok := c.dirPaths[c.Node.Parent]; ok { + sep := string(filepath.Separator) + if strings.HasSuffix(pp, sep) { + sep = "" + } + return pp + sep + strings.ToLower(c.Node.Name) + } + } + return strings.ToLower(c.Tree.Path(c.ID)) +} + type node interface{ eval(*Ctx) bool } type andNode struct{ l, r node } @@ -154,7 +173,4 @@ func (n numNode) eval(c *Ctx) bool { } // Match evaluates the query against one node. -func (q *Query) Match(c *Ctx) bool { - c.path = "" - return q.root.eval(c) -} +func (q *Query) Match(c *Ctx) bool { return q.root.eval(c) }