Skip to content

Commit 4880531

Browse files
authored
suggestions updates (#1953)
1 parent 539559c commit 4880531

3 files changed

Lines changed: 172 additions & 59 deletions

File tree

pkg/suggestion/filewalk.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package suggestion
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"io/fs"
10+
"os"
11+
"path/filepath"
12+
13+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
14+
)
15+
16+
const ListDirChanSize = 50
17+
18+
type DirEntryResult struct {
19+
Entry fs.DirEntry
20+
Err error
21+
}
22+
23+
func listDirectory(ctx context.Context, dir string, maxFiles int) (<-chan DirEntryResult, error) {
24+
// Open the directory outside the goroutine for early error reporting.
25+
f, err := os.Open(dir)
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
// Ensure we have a directory.
31+
fi, err := f.Stat()
32+
if err != nil {
33+
f.Close()
34+
return nil, err
35+
}
36+
if !fi.IsDir() {
37+
f.Close()
38+
return nil, fmt.Errorf("%s is not a directory", dir)
39+
}
40+
41+
ch := make(chan DirEntryResult, ListDirChanSize)
42+
go func() {
43+
defer close(ch)
44+
// Make sure to close the directory when done.
45+
defer f.Close()
46+
47+
// Read up to maxFiles entries.
48+
entries, err := f.ReadDir(maxFiles)
49+
if err != nil {
50+
utilfn.SendWithCtxCheck(ctx, ch, DirEntryResult{Err: err})
51+
return
52+
}
53+
54+
// Send each entry over the channel.
55+
for _, entry := range entries {
56+
ok := utilfn.SendWithCtxCheck(ctx, ch, DirEntryResult{Entry: entry})
57+
if !ok {
58+
return
59+
}
60+
}
61+
62+
// Add parent directory (“..”) entry if not at the filesystem root.
63+
if filepath.Dir(dir) != dir {
64+
mockDir := &MockDirEntry{
65+
NameStr: "..",
66+
IsDirVal: true,
67+
FileMode: fs.ModeDir | 0755,
68+
}
69+
utilfn.SendWithCtxCheck(ctx, ch, DirEntryResult{Entry: mockDir})
70+
}
71+
}()
72+
return ch, nil
73+
}

pkg/suggestion/suggestion.go

Lines changed: 90 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package suggestion
55

66
import (
7+
"container/heap"
78
"context"
89
"fmt"
910
"io/fs"
@@ -322,111 +323,141 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat
322323
}, nil
323324
}
324325

325-
// FetchSuggestions returns file suggestions using junegunn/fzf’s fuzzy matching.
326+
// Define a scored entry for fuzzy matching.
327+
type scoredEntry struct {
328+
ent fs.DirEntry
329+
score int
330+
fileName string
331+
positions []int
332+
}
333+
334+
// We'll use a heap to only keep the top MaxSuggestions when a search term is provided.
335+
// Define a min-heap so that the worst (lowest scoring) candidate is at the top.
336+
type scoredEntryHeap []scoredEntry
337+
338+
// Less: lower score is “less”. For equal scores, a candidate with a longer filename is considered worse.
339+
func (h scoredEntryHeap) Len() int { return len(h) }
340+
func (h scoredEntryHeap) Less(i, j int) bool {
341+
if h[i].score != h[j].score {
342+
return h[i].score < h[j].score
343+
}
344+
return len(h[i].fileName) > len(h[j].fileName)
345+
}
346+
func (h scoredEntryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
347+
func (h *scoredEntryHeap) Push(x interface{}) { *h = append(*h, x.(scoredEntry)) }
348+
func (h *scoredEntryHeap) Pop() interface{} {
349+
old := *h
350+
n := len(old)
351+
x := old[n-1]
352+
*h = old[0 : n-1]
353+
return x
354+
}
355+
326356
func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {
327357
// Only support file suggestions.
328358
if data.SuggestionType != "file" {
329359
return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType)
330360
}
331361

332-
// Resolve the base directory, the query prefix (for display) and the search term.
362+
// Resolve the base directory, query prefix (for display) and search term.
333363
baseDir, queryPrefix, searchTerm, err := resolveFileQuery(data.FileCwd, data.Query)
334364
if err != nil {
335365
return nil, fmt.Errorf("error resolving base dir: %w", err)
336366
}
337367

338-
dirFd, err := os.Open(baseDir)
339-
if err != nil {
340-
return nil, fmt.Errorf("error opening directory: %w", err)
341-
}
342-
defer dirFd.Close()
343-
344-
finfo, err := dirFd.Stat()
345-
if err != nil {
346-
return nil, fmt.Errorf("error getting directory info: %w", err)
347-
}
348-
if !finfo.IsDir() {
349-
return nil, fmt.Errorf("not a directory: %s", baseDir)
350-
}
368+
// Use a cancellable context for directory listing.
369+
listingCtx, cancelFn := context.WithCancel(context.Background())
370+
defer cancelFn()
351371

352-
// Read up to 1000 entries.
353-
dirEnts, err := dirFd.ReadDir(1000)
372+
entriesCh, err := listDirectory(listingCtx, baseDir, 1000)
354373
if err != nil {
355-
return nil, fmt.Errorf("error reading directory: %w", err)
374+
return nil, fmt.Errorf("error listing directory: %w", err)
356375
}
357376

358-
// Add parent directory (“..”) entry if not at the filesystem root.
359-
if filepath.Dir(baseDir) != baseDir {
360-
dirEnts = append(dirEnts, &MockDirEntry{
361-
NameStr: "..",
362-
IsDirVal: true,
363-
FileMode: fs.ModeDir | 0755,
364-
})
365-
}
377+
const maxEntries = MaxSuggestions // top-k entries
366378

367-
// For fuzzy matching we’ll compute a score for each candidate.
368-
type scoredEntry struct {
369-
ent fs.DirEntry
370-
score int
371-
fileName string
372-
positions []int
373-
}
374-
var scoredEntries []scoredEntry
379+
// Always use a heap.
380+
var topHeap scoredEntryHeap
381+
heap.Init(&topHeap)
375382

376-
// If a search term is provided, convert it to lowercase (per fzf’s API contract).
377383
var patternRunes []rune
378384
if searchTerm != "" {
379385
patternRunes = []rune(strings.ToLower(searchTerm))
380386
}
381387

382-
// Create a slab for temporary allocations in the fzf matching function.
383388
var slab util.Slab
389+
var index int // used for ordering when searchTerm is empty
384390

385-
// Iterate over directory entries.
386-
for _, de := range dirEnts {
391+
// Process each directory entry.
392+
for result := range entriesCh {
393+
if result.Err != nil {
394+
return nil, fmt.Errorf("error reading directory: %w", result.Err)
395+
}
396+
de := result.Entry
387397
fileName := de.Name()
388-
score := 0
398+
var score int
399+
var candidatePositions []int
389400

390-
// If a search term was provided, perform fuzzy matching.
391401
if searchTerm != "" {
392-
// Convert candidate to lowercase for case-insensitive matching.
402+
// Perform fuzzy matching.
393403
candidate := strings.ToLower(fileName)
394404
text := util.ToChars([]byte(candidate))
395-
result, positions := algo.FuzzyMatchV2(false, true, true, &text, patternRunes, true, &slab)
396-
if result.Score <= 0 {
397-
// No match: skip this entry.
405+
matchResult, positions := algo.FuzzyMatchV2(false, true, true, &text, patternRunes, true, &slab)
406+
if matchResult.Score <= 0 {
407+
index++
398408
continue
399409
}
400-
score = result.Score
401-
entry := scoredEntry{ent: de, score: score, fileName: fileName}
410+
score = matchResult.Score
402411
if positions != nil {
403-
entry.positions = *positions
412+
candidatePositions = *positions
404413
}
405-
scoredEntries = append(scoredEntries, entry)
406414
} else {
407-
scoredEntries = append(scoredEntries, scoredEntry{ent: de, score: score, fileName: fileName})
415+
// Use ordering: first entry gets highest score.
416+
score = maxEntries - index
408417
}
409-
}
418+
index++
410419

411-
// Sort entries by descending score (better matches first).
412-
if searchTerm != "" {
413-
sort.Slice(scoredEntries, func(i, j int) bool {
414-
if scoredEntries[i].score != scoredEntries[j].score {
415-
return scoredEntries[i].score > scoredEntries[j].score
420+
se := scoredEntry{
421+
ent: de,
422+
score: score,
423+
fileName: fileName,
424+
positions: candidatePositions,
425+
}
426+
427+
if topHeap.Len() < maxEntries {
428+
heap.Push(&topHeap, se)
429+
} else {
430+
// Replace the worst candidate if this one is better.
431+
worst := topHeap[0]
432+
if se.score > worst.score || (se.score == worst.score && len(se.fileName) < len(worst.fileName)) {
433+
heap.Pop(&topHeap)
434+
heap.Push(&topHeap, se)
416435
}
417-
return len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName)
418-
})
436+
}
437+
if searchTerm == "" && topHeap.Len() >= maxEntries {
438+
break
439+
}
419440
}
420441

421-
// Build up to MaxSuggestions suggestions
442+
// Extract and sort the scored entries (highest score first).
443+
scoredEntries := make([]scoredEntry, topHeap.Len())
444+
copy(scoredEntries, topHeap)
445+
sort.Slice(scoredEntries, func(i, j int) bool {
446+
if scoredEntries[i].score != scoredEntries[j].score {
447+
return scoredEntries[i].score > scoredEntries[j].score
448+
}
449+
return len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName)
450+
})
451+
452+
// Build suggestions from the scored entries.
422453
var suggestions []wshrpc.SuggestionType
423454
for _, candidate := range scoredEntries {
424455
fileName := candidate.ent.Name()
425456
fullPath := filepath.Join(baseDir, fileName)
426457
suggestionFileName := filepath.Join(queryPrefix, fileName)
427458
offset := len(suggestionFileName) - len(fileName)
428459
if offset > 0 && len(candidate.positions) > 0 {
429-
// Adjust the match positions to account for the queryPrefix.
460+
// Adjust match positions to account for the query prefix.
430461
for j := range candidate.positions {
431462
candidate.positions[j] += offset
432463
}

pkg/util/utilfn/utilfn.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,3 +1023,12 @@ func QuickHashString(s string) string {
10231023
h.Write([]byte(s))
10241024
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
10251025
}
1026+
1027+
func SendWithCtxCheck[T any](ctx context.Context, ch chan<- T, val T) bool {
1028+
select {
1029+
case <-ctx.Done():
1030+
return false
1031+
case ch <- val:
1032+
return true
1033+
}
1034+
}

0 commit comments

Comments
 (0)