From 2a26d09cf2ad0a1dd0e00069898578569d6e6b31 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 29 Mar 2026 18:20:52 +0300 Subject: [PATCH 1/2] perf: reduce search latency with synchronous UI-thread search and lock-free tree rebuild --- app/state.go | 3 + data/cmdstore/cmdstore.go | 5 +- data/model/command_entry.go | 13 +-- data/model/prefix_tree_node.go | 1 + data/search/distance.go | 16 ++- data/search/score.go | 64 ++++++++++++ data/syntax/braces.go | 12 +-- data/syntax/braces_test.go | 18 ---- data/tree/build.go | 6 ++ data/tree/flatten.go | 40 ++++++-- main.go | 5 + ui/bar_search.go | 112 +++----------------- ui/tab_treeview.go | 6 ++ ui/workers.go | 182 +++++++++++++++++++++++++++------ 14 files changed, 309 insertions(+), 174 deletions(-) diff --git a/app/state.go b/app/state.go index 5e683b0..192997e 100644 --- a/app/state.go +++ b/app/state.go @@ -181,6 +181,9 @@ type State struct { // COPY-ON-WRITE: always assign a new map, never mutate. // nil when query is empty or no search has been performed. SearchMatchingCommands map[*model.CommandEntry]bool + // Search coordination: dedicated channel so search never blocks behind tree rebuilds. + SearchChan chan struct{} // Buffered(1): coalesces search requests + SearchShutdown chan struct{} // Unbuffered: signal search worker to stop // Protected by StoreMu LoadError error diff --git a/data/cmdstore/cmdstore.go b/data/cmdstore/cmdstore.go index 58efde5..120a79c 100644 --- a/data/cmdstore/cmdstore.go +++ b/data/cmdstore/cmdstore.go @@ -15,6 +15,7 @@ type CmdStore struct { OrderedList []*model.CommandEntry // Commands in order of first appearance PrefixTree *model.PrefixTreeNode // For hierarchical clustering FuzzyIndex *search.Index // For fuzzy searching + treeNodesCount int // Cached count of prefix tree nodes (computed once at build time) TotalCommands int // [only for testing&debugging] Total commands parsed (including duplicates) UniqueCommands int // [only for testing&debugging] Number of unique commands mu sync.RWMutex // Thread-safe access @@ -44,6 +45,7 @@ func (s *CmdStore) Load(commands []*model.CommandEntry) { // Build indices s.PrefixTree = tree.Build(s.OrderedList) tree.PreSortChildren(s.PrefixTree) + s.treeNodesCount = countTreeNodes(s.PrefixTree) s.FuzzyIndex = search.BuildIndex(s.OrderedList) } @@ -113,11 +115,12 @@ func (s *CmdStore) AllCommands() []*model.CommandEntry { } // TreeNodesCount returns the total number of nodes in the prefix tree. +// The count is cached at build time to avoid repeated O(n) tree traversals. func (s *CmdStore) TreeNodesCount() int { s.mu.RLock() defer s.mu.RUnlock() - return countTreeNodes(s.PrefixTree) + return s.treeNodesCount } func countTreeNodes(node *model.PrefixTreeNode) int { diff --git a/data/model/command_entry.go b/data/model/command_entry.go index 7a64407..4b22251 100644 --- a/data/model/command_entry.go +++ b/data/model/command_entry.go @@ -4,12 +4,13 @@ import "github.com/cespare/xxhash/v2" // CommandEntry represents a single unique command with all metadata type CommandEntry struct { - Command string // The actual command text - Frequency int // Number of times this exact command was executed - LineNumbers []int // All line numbers where this command appears - Hash uint64 // Fast hash for deduplication - Order int // Insertion order in OrderedList (higher = more recent) - Shell Shell // Which shell/terminal this came from + Command string // The actual command text + Frequency int // Number of times this exact command was executed + LineNumbers []int // All line numbers where this command appears + Hash uint64 // Fast hash for deduplication + Order int // Insertion order in OrderedList (higher = more recent) + Shell Shell // Which shell/terminal this came from + TreeNode *PrefixTreeNode // Back-pointer to the prefix tree node storing this command; set during tree build } // NewCommandEntry creates a new CommandEntry for a command diff --git a/data/model/prefix_tree_node.go b/data/model/prefix_tree_node.go index 5fa1cca..9f4bf16 100644 --- a/data/model/prefix_tree_node.go +++ b/data/model/prefix_tree_node.go @@ -6,6 +6,7 @@ type PrefixTreeNode struct { FullPath string // Full command path to this node Children map[string]*PrefixTreeNode // Child nodes (next words) SortedChildren []*PrefixTreeNode // Pre-sorted children for rendering (sorted once at build time) + Parent *PrefixTreeNode // Parent node (nil for root); set during tree build CommandCount int // Total commands passing through this node TerminalCount int // Commands ending at this node Commands []*CommandEntry // Commands that end here diff --git a/data/search/distance.go b/data/search/distance.go index 3a2c482..778277e 100644 --- a/data/search/distance.go +++ b/data/search/distance.go @@ -20,8 +20,20 @@ func LevenshteinDistance(s1, s2 string) int { s1, s2 = s2, s1 } - previous := make([]int, len(s1)+1) - current := make([]int, len(s1)+1) + // Stack-allocate for short strings to avoid heap allocations in the hot path. + // 128 covers commands up to 127 chars (the shorter of the two strings). + var ( + prevBuf, curBuf [128]int + previous, current []int + ) + + if len(s1)+1 <= len(prevBuf) { + previous = prevBuf[:len(s1)+1] + current = curBuf[:len(s1)+1] + } else { + previous = make([]int, len(s1)+1) + current = make([]int, len(s1)+1) + } for i := range previous { previous[i] = i diff --git a/data/search/score.go b/data/search/score.go index 493afeb..584c933 100644 --- a/data/search/score.go +++ b/data/search/score.go @@ -48,6 +48,27 @@ func Score(query string, cmd *model.CommandEntry) float64 { queryLower := strings.ToLower(query) cmdLower := strings.ToLower(cmd.Command) + // Fast path for short queries (1-2 chars): skip expensive Levenshtein and + // trigram computations. The similarity bonus is meaningless for very short + // queries (e.g., "g" vs "git commit -m fix" → similarity ≈ 0). Use only + // match type + frequency for ranking. This eliminates thousands of slice + // allocations (2 per Levenshtein call) that dominate the scoring hot path. + if len(queryLower) < MinStringLengthForTrigrams { + matchType, baseScore := determineMatchTypeShort(queryLower, cmdLower) + if matchType == NoMatch { + return 0.0 + } + + freqBonus := math.Min(math.Log(float64(cmd.Frequency+1))/FrequencyBonusDivisor, FrequencyBonusMax) + totalScore := baseScore + freqBonus + + if totalScore < MinScoreThreshold { + return 0.0 + } + + return totalScore + } + // Step 1: Determine match type, base score, and pre-split words/parts matchType, baseScore, words, wordParts := determineMatchType(queryLower, cmdLower) if matchType == NoMatch { @@ -102,6 +123,49 @@ func Score(query string, cmd *model.CommandEntry) float64 { return totalScore } +// isWordPartDelimiter reports whether ch is a word-part delimiter. +// Matches the delimiters used by wordPartReplacer: space, '.', '/', '-', '_', ':'. +func isWordPartDelimiter(ch byte) bool { + switch ch { + case ' ', '.', '/', '-', '_', ':': + return true + } + + return false +} + +// determineMatchTypeShort is a fast path for short queries (< 3 chars). +// Avoids allocations from strings.Fields, splitWordParts, and Levenshtein. +// Only checks prefix, word boundary, and substring matches — no fuzzy matching +// since trigrams require 3+ chars and edit distance is meaningless for 1-2 chars. +func determineMatchTypeShort(query, cmd string) (MatchType, float64) { + if query == cmd { + return ExactMatch, ScoreExactMatch + } + + if strings.HasPrefix(cmd, query) { + return PrefixMatch, ScorePrefixMatch + } + + // Check word boundaries without allocating a slice: scan for query preceded + // by start-of-string or delimiter, and followed by end-of-string or delimiter. + if idx := strings.Index(cmd, query); idx >= 0 { + // Check if it's a word match (at word boundary) + atStart := idx == 0 || isWordPartDelimiter(cmd[idx-1]) + end := idx + len(query) + atEnd := end == len(cmd) || isWordPartDelimiter(cmd[end]) + + if atStart && atEnd { + return WordMatch, ScoreWordMatch + } + + // It's at least a substring match + return SubstringMatch, ScoreSubstringMatch + } + + return NoMatch, 0.0 +} + // determineMatchType identifies how the query matches the command. // Returns the match type, corresponding base score, the whitespace-split words of cmd, // and pre-computed delimiter-split parts per word (nil for exact/prefix matches). diff --git a/data/syntax/braces.go b/data/syntax/braces.go index 30ccf22..dadd85e 100644 --- a/data/syntax/braces.go +++ b/data/syntax/braces.go @@ -153,18 +153,8 @@ func doBlockMatcher(word string) int { } } -// IsBalancedDoBlock checks if do/done keywords are balanced in a command, -// respecting quotes. Used for bash/zsh loop constructs (for, while, until, select). -// Keywords must appear at word boundaries to avoid matching "docker", "undone", etc. -// Returns true when depth==0, including when no do/done keywords exist. -func IsBalancedDoBlock(command string) bool { - depth, _ := scanKeywordBalance(command, doBlockMatcher) - return depth == 0 -} - // HasBalancedDoBlock checks if a command has a complete, balanced do/done block. -// Unlike IsBalancedDoBlock (which returns true when depth==0, including when no keywords exist), -// this function returns true only when do/done keywords are present AND balanced. +// Returns true only when do/done keywords are present AND balanced. // Used by shell parsers to determine if a loop construct is complete. func HasBalancedDoBlock(command string) bool { depth, found := scanKeywordBalance(command, doBlockMatcher) diff --git a/data/syntax/braces_test.go b/data/syntax/braces_test.go index ee52f3a..fca6cb2 100644 --- a/data/syntax/braces_test.go +++ b/data/syntax/braces_test.go @@ -102,24 +102,6 @@ func TestBalancedBraces(t *testing.T) { } } -func TestIsBalancedDoBlock(t *testing.T) { - runBoolTests(t, "IsBalancedDoBlock", []boolTestCase{ - {"balanced for loop", "for x in 1 2; do echo $x; done", true}, - {"balanced while loop", "while true; do sleep 1; done", true}, - {"unbalanced - missing done", "for x in 1 2; do echo $x", false}, - {"unbalanced - extra done", "done", false}, - {"nested loops", "for i in 1; do for j in 2; do echo; done; done", true}, - {"no do/done at all", "echo hello", true}, - {"do/done in quotes ignored", `echo "do something done"`, true}, - {"docker not matched", "docker run nginx", true}, - {"undone not matched", "echo undone", true}, - {"dofile not matched", "dofile something", true}, - {"do at end of string", "for x in 1; do", false}, - {"done at start", "done; do echo; done", false}, - {"empty input", "", true}, - }, IsBalancedDoBlock) -} - func TestIsBalancedFishBlock(t *testing.T) { runBoolTests(t, "IsBalancedFishBlock", []boolTestCase{ {"balanced for loop", "for x in 1 2; echo $x; end", true}, diff --git a/data/tree/build.go b/data/tree/build.go index 04efb19..21f6ee5 100644 --- a/data/tree/build.go +++ b/data/tree/build.go @@ -40,6 +40,7 @@ func insertIntoTree(node *model.PrefixTreeNode, tokens []string, cmd *model.Comm if len(tokens) == 0 { node.TerminalCount++ node.Commands = append(node.Commands, cmd) + cmd.TreeNode = node return } @@ -56,6 +57,7 @@ func insertIntoTree(node *model.PrefixTreeNode, tokens []string, cmd *model.Comm current.TerminalCount++ current.Commands = append(current.Commands, cmd) + cmd.TreeNode = current } // SortNodesByCommandCount sorts nodes by command count (descending), then alphabetically for stable ordering @@ -112,6 +114,7 @@ func getOrCreateChild(parent *model.PrefixTreeNode, token string, sb *strings.Bu Word: token, FullPath: sb.String(), Children: make(map[string]*model.PrefixTreeNode), + Parent: parent, Level: parent.Level + 1, } parent.Children[token] = child @@ -184,9 +187,11 @@ func insertPipelineStructure(root *model.PrefixTreeNode, parts []string, cmd *mo if lastPipedChild != nil { lastPipedChild.Commands = append(lastPipedChild.Commands, cmd) lastPipedChild.TerminalCount++ + cmd.TreeNode = lastPipedChild } else { currentNode.Commands = append(currentNode.Commands, cmd) currentNode.TerminalCount++ + cmd.TreeNode = currentNode } } @@ -233,4 +238,5 @@ func insertFlatStructure(root *model.PrefixTreeNode, parts []string, operators [ // Attach the command to the final leaf node lastNode.Commands = append(lastNode.Commands, cmd) lastNode.TerminalCount++ + cmd.TreeNode = lastNode } diff --git a/data/tree/flatten.go b/data/tree/flatten.go index 849e01f..d7766f8 100644 --- a/data/tree/flatten.go +++ b/data/tree/flatten.go @@ -52,11 +52,14 @@ func FlattenForDisplay( sourceMatchSet map[*model.PrefixTreeNode]bool ) + // Search match set: walk UP from each matching command's tree node via parent pointers. + // This is O(matches × depth) instead of O(total_tree_nodes), dramatically faster for + // search queries that match a small subset of commands. if matchingCommands != nil { - searchMatchSet = make(map[*model.PrefixTreeNode]bool) - buildSearchMatchSet(root, matchingCommands, searchMatchSet) + searchMatchSet = buildSearchMatchSetFromCommands(matchingCommands) } + // Source match set: uses full tree walk since shell filter typically matches most nodes. if shellFilter != nil { sourceMatchSet = make(map[*model.PrefixTreeNode]bool) buildSourceMatchSet(root, shellFilter, sourceMatchSet) @@ -148,15 +151,30 @@ func buildMatchSet( return hasMatch } -// buildSearchMatchSet pre-computes which nodes have matching commands (O(n) single pass) -func buildSearchMatchSet( - node *model.PrefixTreeNode, - matchingCommands map[*model.CommandEntry]bool, - result map[*model.PrefixTreeNode]bool, -) bool { - return buildMatchSet(node, func(cmd *model.CommandEntry) bool { - return matchingCommands[cmd] - }, result) +// buildSearchMatchSetFromCommands builds the search match set by walking UP from +// each matching command's tree node via parent pointers, marking all ancestors. +// Complexity: O(matches × tree_depth) — dramatically faster than a full tree walk +// when the match set is small relative to the total tree size. +// Short-circuits when it hits an already-marked ancestor (common for commands sharing prefixes). +func buildSearchMatchSetFromCommands(matchingCommands map[*model.CommandEntry]bool) map[*model.PrefixTreeNode]bool { + // Each matching command contributes itself + ~2 ancestors on average. + const estimatedAncestorsPerMatch = 3 + + result := make(map[*model.PrefixTreeNode]bool, len(matchingCommands)*estimatedAncestorsPerMatch) + + for cmd := range matchingCommands { + node := cmd.TreeNode + for node != nil { + if result[node] { + break // ancestor already marked, all further ancestors are too + } + + result[node] = true + node = node.Parent + } + } + + return result } // buildSourceMatchSet pre-computes which nodes have commands matching shell filter (O(n) single pass) diff --git a/main.go b/main.go index d5970d5..d8ac8c7 100644 --- a/main.go +++ b/main.go @@ -645,6 +645,9 @@ func run(p *runParams) *savedUIState { ResultChan: make(chan appstate.HotkeyResult, 1), }, + SearchChan: make(chan struct{}, 1), // Buffered(1): coalesces search requests + SearchShutdown: make(chan struct{}), // Unbuffered: signal search worker to stop + ShellFilter: nil, // nil = show all shells ShellBadges: make(map[string]*widget.Clickable), // Initialize badge widgets map @@ -698,6 +701,7 @@ func run(p *runParams) *savedUIState { // (StoreShutdown, TreeRebuildShutdown, StatsRebuildShutdown), // so they stop cleanly when the window is closed. ui.StartBackgroundParser(appState) + ui.StartSearchWorker(appState) ui.StartTreeRebuildWorker(appState) ui.StartStatsRebuildWorker(appState) @@ -801,6 +805,7 @@ func run(p *runParams) *savedUIState { appState.StoreMu.RUnlock() // Signal rebuild workers to shutdown for this window cycle + close(appState.SearchShutdown) close(appState.Tree.RebuildShutdown) close(appState.Stats.RebuildShutdown) close(appState.StoreShutdown) diff --git a/ui/bar_search.go b/ui/bar_search.go index 0a3a3f9..63929f8 100644 --- a/ui/bar_search.go +++ b/ui/bar_search.go @@ -2,7 +2,6 @@ package ui import ( "image/color" - "log" "gioui.org/io/key" "gioui.org/io/pointer" @@ -10,7 +9,6 @@ import ( "gioui.org/widget/material" appstate "github.com/debrief-dev/debrief/app" - "github.com/debrief-dev/debrief/data/model" ) func renderSearchInput(gtx C, app *appstate.State, theme *material.Theme) D { @@ -71,7 +69,10 @@ func renderSearchInput(gtx C, app *appstate.State, theme *material.Theme) D { return dims } -// handleSearchInput handles changes to the search input +// handleSearchInput handles changes to the search input. +// Runs search + tree rebuild synchronously on the UI thread (~1-3ms) so that +// renderTreeView (which runs later in the same frame) sees the results immediately. +// This gives 0-frame latency — the user sees results on the same frame they type. // //nolint:dupl // selection-save blocks operate on different state (commands vs tree) func handleSearchInput(app *appstate.State) { @@ -103,10 +104,6 @@ func handleSearchInput(app *appstate.State) { // Reset command selection when user types (return to search mode) app.Commands.SelectedIndex = -1 // UI-only state app.NeedScrollToSel = false - - // Don't clear tree selection here — it causes visible blinking because - // the tree rebuild is async. NeedInitialSel will overwrite the selection - // once the rebuild completes, providing a seamless transition. } // Always update current query @@ -116,94 +113,17 @@ func handleSearchInput(app *appstate.State) { // Only trigger search if query actually changed if queryChanged { - // Execute search immediately - // Tree will be marked for rebuild when search completes - executeSearch(app, currentText) - } -} - -// executeSearch performs the actual search -func executeSearch(app *appstate.State, query string) { - app.StoreMu.Lock() - - executeSearchLocked(app, query) - // Request async tree rebuild — must NOT be synchronous because renderSearchInput - // runs AFTER renderTreeView in the same frame. A sync rebuild would replace node - // pointers mid-frame, breaking Gio event target matching for clicks and hovers. - RequestTreeRebuild(app) - - app.StoreMu.Unlock() - - // Re-pin list to bottom (ScrollToEnd mode). Safe to write Position - // here because executeSearch is only called from the UI thread. - app.Commands.List.Position.BeforeEnd = false - - app.Window.Invalidate() -} - -// executeSearchLocked performs search (must be called with parserMu locked) -func executeSearchLocked(app *appstate.State, query string) { - // Invalidate height caches when search query changes (different items displayed) - invalidateHeightCaches(app) - - if query == "" { - // Empty search: show all loaded commands (respecting shell filter) - if app.ShellFilter != nil { - // Apply shell filter - filtered := make([]*model.CommandEntry, 0, len(app.Commands.LoadedCommands)) - - for _, cmd := range app.Commands.LoadedCommands { - if app.ShellFilter[cmd.Shell] { - filtered = append(filtered, cmd) - } - } - - app.Commands.DisplayCommands = filtered - } else { - app.Commands.DisplayCommands = app.Commands.LoadedCommands - } - - app.SearchMatchingCommands = nil - app.Commands.NeedInitialSel = true - app.Tree.NeedInitialSel = true - - log.Printf("Search cleared, showing %d loaded commands", len(app.Commands.DisplayCommands)) - - return - } - - if app.Store == nil { - return - } - - // Perform fuzzy search - log.Printf("Executing search for query: '%s'", query) - results := app.Store.Search(query) - - // Build matching commands map (for tree rebuild reuse) and DisplayCommands in one pass. - // results is sorted best-to-worst; DisplayCommands needs worst-to-best (best near search bar). - // Iterating in reverse gives the correct display order while populating the map. - matchingMap := make(map[*model.CommandEntry]bool, len(results)) - - display := make([]*model.CommandEntry, 0, len(results)) - for i := len(results) - 1; i >= 0; i-- { - entry := results[i].Entry - - matchingMap[entry] = true - if app.ShellFilter == nil || app.ShellFilter[entry.Shell] { - display = append(display, entry) - } + // Run search + tree rebuild synchronously on the UI thread (~1-3ms). + // Gio's Flex lays out Rigid children (search bar) BEFORE Flexed children + // (tree view), so results computed here are visible to renderTreeView + // in the SAME frame — zero latency. + performSearch(app) + + // Rebuild tree inline for same-frame display. + rebuildTree(app) + + // Re-pin list to bottom (ScrollToEnd mode). Safe to write Position + // here because we're on the UI thread. + app.Commands.List.Position.BeforeEnd = false } - - app.SearchMatchingCommands = matchingMap - app.Commands.DisplayCommands = display - log.Printf("Search completed - found %d matches", len(display)) - - // Auto-select the best match - if len(app.Commands.DisplayCommands) > 0 { - app.Commands.NeedInitialSel = true - } - - // Also request initial selection for tree tab after search - app.Tree.NeedInitialSel = true } diff --git a/ui/tab_treeview.go b/ui/tab_treeview.go index 92b5715..a67656e 100644 --- a/ui/tab_treeview.go +++ b/ui/tab_treeview.go @@ -196,8 +196,14 @@ func renderTreeView(gtx C, app *appstate.State, theme *material.Theme) D { selectedTreeNode = app.Tree.SelectedNode app.StoreMu.Unlock() + needScrollToSel = true // Update local var so scroll runs in the SAME frame app.NeedScrollToSel = true + app.Tree.NeedInitialSel = false + } else if app.Tree.NeedInitialSel { + if len(nodes) == 0 { + app.Tree.NeedInitialSel = false + } } // Handle empty state diff --git a/ui/workers.go b/ui/workers.go index a8fc239..989d32f 100644 --- a/ui/workers.go +++ b/ui/workers.go @@ -198,13 +198,17 @@ func initializeCommandsLocked(state *appstate.State) { RequestTreeRebuild(state) requestStatsRebuild(state) - // If no search active, show loaded commands (respecting shell filter if active) + // If no search active, show loaded commands (respecting shell filter if active). + // If a search IS active, signal the background worker to re-run it against + // the new data rather than running it synchronously under Lock (which would + // block the UI thread for the entire search duration). if state.CurrentQuery == "" { // Apply shell filter applyShellFilterLocked(state) } else { - // Re-apply search to new data - executeSearchLocked(state, state.CurrentQuery) + // Signal the search worker to re-run against new data rather than + // running synchronously under Lock (which blocks the UI thread). + requestSearch(state) } // ScrollToEnd on the List handles pinning to bottom automatically. @@ -225,13 +229,43 @@ func RequestTreeRebuild(app *appstate.State) { } } +// StartSearchWorker starts a dedicated background goroutine for fuzzy search. +// Runs independently of the tree rebuild worker so search is never blocked by +// a slow tree rebuild in progress. +func StartSearchWorker(state *appstate.State) { + go func() { + for { + select { + case <-state.SearchChan: + // Background search path: used by initializeCommandsLocked (data reloads). + // User-initiated searches run synchronously on the UI thread for 0-frame latency. + performSearch(state) + performAsyncTreeRebuild(state) + case <-state.SearchShutdown: + log.Println("Search worker shutting down") + return + } + } + }() + + log.Println("Search worker started") +} + +// requestSearch signals the search worker to run a search. +func requestSearch(app *appstate.State) { + select { + case app.SearchChan <- struct{}{}: + default: + // Already has pending request + } +} + // StartTreeRebuildWorker starts a background goroutine to rebuild the tree asynchronously func StartTreeRebuildWorker(state *appstate.State) { go func() { for { select { case <-state.Tree.RebuildChan: - // the rebuild uses cached fuzzy results as pre-filter. performAsyncTreeRebuild(state) // Notify waiters via broadcast: close current channel, create fresh one @@ -250,19 +284,26 @@ func StartTreeRebuildWorker(state *appstate.State) { log.Println("Tree rebuild worker started") } -// rebuildTreeLocked rebuilds the tree synchronously. -// Must be called with StoreMu held (Lock or RLock depending on caller). -// Caller must ensure store is non-nil. +// treeRebuildResult holds the computed output of a tree rebuild. +// Produced by computeTreeRebuild (lock-free) and consumed by performAsyncTreeRebuild. +type treeRebuildResult struct { + nodes []*model.TreeDisplayNode + pathIndex map[string]int + bestMatch int +} + +// computeTreeRebuild computes a new tree display from immutable inputs. +// This is a pure function — it holds no locks and touches no shared state. // // Uses substring matching (not fuzzy search) for tree filtering so the tree // only shows commands that literally contain the query. Fuzzy matches produce // too much clutter in the hierarchical tree view. -func rebuildTreeLocked(state *appstate.State) { - store := state.Store - currentQuery := state.CurrentQuery - shellFilter := state.ShellFilter - cachedMatchingCommands := state.SearchMatchingCommands - +func computeTreeRebuild( + store *cmdstore.CmdStore, + currentQuery string, + shellFilter map[model.Shell]bool, + cachedMatchingCommands map[*model.CommandEntry]bool, +) treeRebuildResult { // Extract tree data from store (immutable after creation) treeRoot := store.Tree() @@ -342,34 +383,117 @@ func rebuildTreeLocked(state *appstate.State) { } } - // Swap results - state.Tree.Nodes = newTreeNodes - state.Tree.NodePathIndex = newTreeNodeIndex - state.Tree.BestMatchIndex = bestMatchIndex - state.Tree.NodesGeneration++ - state.Tree.NeedsRebuild.Store(false) + return treeRebuildResult{ + nodes: newTreeNodes, + pathIndex: newTreeNodeIndex, + bestMatch: bestMatchIndex, + } +} + +// performSearch runs the fuzzy search using snapshot-compute-swap. +// The expensive scoring runs without any lock so the UI thread stays responsive. +// Called from both the UI thread (synchronous search) and the background search worker. +func performSearch(state *appstate.State) { + // Snapshot inputs under brief RLock + state.StoreMu.RLock() + store := state.Store + if store == nil { + state.StoreMu.RUnlock() + return + } + + query := state.CurrentQuery + shellFilter := state.ShellFilter + loadedCommands := state.Commands.LoadedCommands + state.StoreMu.RUnlock() + + // Compute without lock — Store.Search uses its own internal lock + var ( + matchingMap map[*model.CommandEntry]bool + display []*model.CommandEntry + ) + + if query == "" { + // Empty search: show all loaded commands (respecting shell filter) + if shellFilter != nil { + filtered := make([]*model.CommandEntry, 0, len(loadedCommands)) + for _, cmd := range loadedCommands { + if shellFilter[cmd.Shell] { + filtered = append(filtered, cmd) + } + } + + display = filtered + } else { + display = loadedCommands + } + } else { + results := store.Search(query) + + matchingMap = make(map[*model.CommandEntry]bool, len(results)) + + display = make([]*model.CommandEntry, 0, len(results)) + for i := len(results) - 1; i >= 0; i-- { + entry := results[i].Entry + + matchingMap[entry] = true + if shellFilter == nil || shellFilter[entry.Shell] { + display = append(display, entry) + } + } + } + + // Brief Lock to swap results + state.StoreMu.Lock() + + state.SearchMatchingCommands = matchingMap + state.Commands.DisplayCommands = display invalidateHeightCaches(state) - log.Printf("[TREE] Rebuild completed: %d nodes", len(newTreeNodes)) + if len(display) > 0 { + state.Commands.NeedInitialSel = true + } + + state.Tree.NeedInitialSel = true + state.StoreMu.Unlock() } -// performAsyncTreeRebuild rebuilds the tree from the background worker. -// Used for non-search rebuilds (data reload, shell filter changes). -func performAsyncTreeRebuild(state *appstate.State) { - // Check store exists with brief RLock +// rebuildTree runs the tree rebuild: snapshot inputs, compute without lock, swap results. +// Called from both the UI thread (synchronous search) and background workers. +func rebuildTree(state *appstate.State) { + // Snapshot inputs under brief RLock state.StoreMu.RLock() - hasStore := state.Store != nil - state.StoreMu.RUnlock() - if !hasStore { + store := state.Store + if store == nil { + state.StoreMu.RUnlock() return } - // rebuildTreeLocked needs write access (swaps Tree.Nodes etc.) + currentQuery := state.CurrentQuery + shellFilter := state.ShellFilter + cachedMatching := state.SearchMatchingCommands + state.StoreMu.RUnlock() + + // Compute new tree without any lock held + result := computeTreeRebuild(store, currentQuery, shellFilter, cachedMatching) + + // Brief Lock to swap results state.StoreMu.Lock() - rebuildTreeLocked(state) + state.Tree.Nodes = result.nodes + state.Tree.NodePathIndex = result.pathIndex + state.Tree.BestMatchIndex = result.bestMatch + state.Tree.NodesGeneration++ + state.Tree.NeedsRebuild.Store(false) + invalidateHeightCaches(state) state.StoreMu.Unlock() + log.Printf("[TREE] Rebuild completed: %d nodes", len(result.nodes)) +} + +// performAsyncTreeRebuild rebuilds the tree from a background worker and notifies the UI. +func performAsyncTreeRebuild(state *appstate.State) { + rebuildTree(state) state.MarkDirty() } From a40d6cf07f815f69181c8130b9ae0b04ec53fb46 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 29 Mar 2026 18:24:24 +0300 Subject: [PATCH 2/2] golangci-lint run --fix --- ui/workers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/workers.go b/ui/workers.go index 989d32f..6c1bc19 100644 --- a/ui/workers.go +++ b/ui/workers.go @@ -396,6 +396,7 @@ func computeTreeRebuild( func performSearch(state *appstate.State) { // Snapshot inputs under brief RLock state.StoreMu.RLock() + store := state.Store if store == nil { state.StoreMu.RUnlock()