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
3 changes: 3 additions & 0 deletions app/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion data/cmdstore/cmdstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 7 additions & 6 deletions data/model/command_entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions data/model/prefix_tree_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions data/search/distance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions data/search/score.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down
12 changes: 1 addition & 11 deletions data/syntax/braces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 0 additions & 18 deletions data/syntax/braces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
6 changes: 6 additions & 0 deletions data/tree/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
}
40 changes: 29 additions & 11 deletions data/tree/flatten.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading