|
4 | 4 | package suggestion |
5 | 5 |
|
6 | 6 | import ( |
| 7 | + "container/heap" |
7 | 8 | "context" |
8 | 9 | "fmt" |
9 | 10 | "io/fs" |
@@ -322,111 +323,141 @@ func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsDat |
322 | 323 | }, nil |
323 | 324 | } |
324 | 325 |
|
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 | + |
326 | 356 | func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) { |
327 | 357 | // Only support file suggestions. |
328 | 358 | if data.SuggestionType != "file" { |
329 | 359 | return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType) |
330 | 360 | } |
331 | 361 |
|
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. |
333 | 363 | baseDir, queryPrefix, searchTerm, err := resolveFileQuery(data.FileCwd, data.Query) |
334 | 364 | if err != nil { |
335 | 365 | return nil, fmt.Errorf("error resolving base dir: %w", err) |
336 | 366 | } |
337 | 367 |
|
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() |
351 | 371 |
|
352 | | - // Read up to 1000 entries. |
353 | | - dirEnts, err := dirFd.ReadDir(1000) |
| 372 | + entriesCh, err := listDirectory(listingCtx, baseDir, 1000) |
354 | 373 | if err != nil { |
355 | | - return nil, fmt.Errorf("error reading directory: %w", err) |
| 374 | + return nil, fmt.Errorf("error listing directory: %w", err) |
356 | 375 | } |
357 | 376 |
|
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 |
366 | 378 |
|
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) |
375 | 382 |
|
376 | | - // If a search term is provided, convert it to lowercase (per fzf’s API contract). |
377 | 383 | var patternRunes []rune |
378 | 384 | if searchTerm != "" { |
379 | 385 | patternRunes = []rune(strings.ToLower(searchTerm)) |
380 | 386 | } |
381 | 387 |
|
382 | | - // Create a slab for temporary allocations in the fzf matching function. |
383 | 388 | var slab util.Slab |
| 389 | + var index int // used for ordering when searchTerm is empty |
384 | 390 |
|
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 |
387 | 397 | fileName := de.Name() |
388 | | - score := 0 |
| 398 | + var score int |
| 399 | + var candidatePositions []int |
389 | 400 |
|
390 | | - // If a search term was provided, perform fuzzy matching. |
391 | 401 | if searchTerm != "" { |
392 | | - // Convert candidate to lowercase for case-insensitive matching. |
| 402 | + // Perform fuzzy matching. |
393 | 403 | candidate := strings.ToLower(fileName) |
394 | 404 | 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++ |
398 | 408 | continue |
399 | 409 | } |
400 | | - score = result.Score |
401 | | - entry := scoredEntry{ent: de, score: score, fileName: fileName} |
| 410 | + score = matchResult.Score |
402 | 411 | if positions != nil { |
403 | | - entry.positions = *positions |
| 412 | + candidatePositions = *positions |
404 | 413 | } |
405 | | - scoredEntries = append(scoredEntries, entry) |
406 | 414 | } else { |
407 | | - scoredEntries = append(scoredEntries, scoredEntry{ent: de, score: score, fileName: fileName}) |
| 415 | + // Use ordering: first entry gets highest score. |
| 416 | + score = maxEntries - index |
408 | 417 | } |
409 | | - } |
| 418 | + index++ |
410 | 419 |
|
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) |
416 | 435 | } |
417 | | - return len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName) |
418 | | - }) |
| 436 | + } |
| 437 | + if searchTerm == "" && topHeap.Len() >= maxEntries { |
| 438 | + break |
| 439 | + } |
419 | 440 | } |
420 | 441 |
|
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. |
422 | 453 | var suggestions []wshrpc.SuggestionType |
423 | 454 | for _, candidate := range scoredEntries { |
424 | 455 | fileName := candidate.ent.Name() |
425 | 456 | fullPath := filepath.Join(baseDir, fileName) |
426 | 457 | suggestionFileName := filepath.Join(queryPrefix, fileName) |
427 | 458 | offset := len(suggestionFileName) - len(fileName) |
428 | 459 | 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. |
430 | 461 | for j := range candidate.positions { |
431 | 462 | candidate.positions[j] += offset |
432 | 463 | } |
|
0 commit comments