waveterm/pkg/suggestion/suggestion.go
2025-02-12 14:00:20 -08:00

486 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package suggestion
import (
"container/heap"
"context"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/util"
"github.com/wavetermdev/waveterm/pkg/faviconcache"
"github.com/wavetermdev/waveterm/pkg/util/fileutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
const MaxSuggestions = 50
type MockDirEntry struct {
NameStr string
IsDirVal bool
FileMode fs.FileMode
}
func (m *MockDirEntry) Name() string { return m.NameStr }
func (m *MockDirEntry) IsDir() bool { return m.IsDirVal }
func (m *MockDirEntry) Type() fs.FileMode { return m.FileMode }
func (m *MockDirEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrInvalid }
var PathSepStr = string(os.PathSeparator)
// ensureTrailingSlash makes sure s ends with a slash.
func ensureTrailingSlash(s string) string {
if s == "" {
return s
}
if !strings.HasSuffix(s, PathSepStr) {
return s + PathSepStr
}
return s
}
// resolveFileQuery returns (baseDir, queryPrefix, searchTerm, error).
//
// Our approach is to use the presence of a trailing slash to decide whether
// to treat the query as a directory listing (searchTerm is empty) or a search
// filter. (This means that a query of exactly "." or ".." is treated as a
// search filter, so that files with a dot in their nameincluding ".."will
// be returned.)
//
// In addition, if there is a slash anywhere in the query (but not at the end),
// we treat everything before the last slash as a relative directory to search
// in, and the portion after the last slash as the search term.
func resolveFileQuery(cwd string, query string) (string, string, string, error) {
// If no current working directory, default to "~".
if cwd == "" {
cwd = "~"
}
var err error
cwd, err = wavebase.ExpandHomeDir(cwd)
if err != nil {
return "", "", "", fmt.Errorf("error expanding home dir: %w", err)
}
if query == "" {
return cwd, "", "", nil
}
// Expand home if needed.
tildeSlash := "~" + PathSepStr
if query == "~" || strings.HasPrefix(query, tildeSlash) {
ogQuery := query
query, err = wavebase.ExpandHomeDir(query)
if err != nil {
return "", "", "", fmt.Errorf("error expanding query home dir: %w", err)
}
if ogQuery == "~" || ogQuery == tildeSlash {
return query, tildeSlash, "", nil
}
}
// Handle absolute queries.
if filepath.IsAbs(query) {
if filepath.Dir(query) == query {
return query, query, "", nil
}
if strings.HasSuffix(query, PathSepStr) {
// Remove trailing slash for canonical directory path.
baseDir := strings.TrimRight(query, PathSepStr)
// But keep the trailing slash in the queryPrefix for display.
queryPrefix := query
return baseDir, queryPrefix, "", nil
}
// Otherwise, e.g. "/var/f"
baseDir := filepath.Dir(query)
queryPrefix := filepath.Dir(query)
searchTerm := filepath.Base(query)
return baseDir, queryPrefix, searchTerm, nil
}
// For relative queries:
// If the query ends with a slash (e.g. "./" or "waveterm/"), then treat it
// as a directory listing.
if strings.HasSuffix(query, PathSepStr) {
fullPath := filepath.Join(cwd, query)
baseDir := strings.TrimRight(fullPath, PathSepStr)
queryPrefix := query
return baseDir, queryPrefix, "", nil
}
// If there is a slash in the query, split into directory part and search term.
if idx := strings.LastIndex(query, PathSepStr); idx != -1 {
dirPart := query[:idx]
term := query[idx+1:]
baseDir := filepath.Join(cwd, dirPart)
// For display purposes, set queryPrefix to the dirPart with a trailing slash.
queryPrefix := ""
if dirPart != "" {
queryPrefix = ensureTrailingSlash(dirPart)
}
return baseDir, queryPrefix, term, nil
}
// No slash in query: search in the cwd.
return cwd, "", query, nil
}
func FetchSuggestions(ctx context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {
if data.SuggestionType == "file" {
return fetchFileSuggestions(ctx, data)
}
if data.SuggestionType == "bookmark" {
return fetchBookmarkSuggestions(ctx, data)
}
return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType)
}
func filterBookmarksForValid(bookmarks map[string]wconfig.WebBookmark) map[string]wconfig.WebBookmark {
validBookmarks := make(map[string]wconfig.WebBookmark)
for k, v := range bookmarks {
if v.Url == "" {
continue
}
u, err := url.ParseRequestURI(v.Url)
if err != nil || u.Scheme == "" || u.Host == "" {
continue
}
validBookmarks[k] = v
}
return validBookmarks
}
func fetchBookmarkSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {
if data.SuggestionType != "bookmark" {
return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType)
}
// scoredEntry holds a bookmark along with its computed score, the match positions for the
// field that will be used for display, the positions for the secondary field (if any),
// and its original index in the Bookmarks list.
type scoredEntry struct {
bookmark wconfig.WebBookmark
score int
matchPos []int // positions for the field that's used as Display
subMatchPos []int // positions for the other field (if any)
origIndex int
}
bookmarks := wconfig.GetWatcher().GetFullConfig().Bookmarks
bookmarks = filterBookmarksForValid(bookmarks)
searchTerm := data.Query
var patternRunes []rune
if searchTerm != "" {
patternRunes = []rune(strings.ToLower(searchTerm))
}
var scoredEntries []scoredEntry
var slab util.Slab
bookmarkKeys := utilfn.GetMapKeys(bookmarks)
// sort by display:order and then by key
sort.Slice(bookmarkKeys, func(i, j int) bool {
bookmarkA := bookmarks[bookmarkKeys[i]]
bookmarkB := bookmarks[bookmarkKeys[j]]
if bookmarkA.DisplayOrder != bookmarkB.DisplayOrder {
return bookmarkA.DisplayOrder < bookmarkB.DisplayOrder
}
return bookmarkKeys[i] < bookmarkKeys[j]
})
for i, bmkey := range bookmarkKeys {
bookmark := bookmarks[bmkey]
// If no search term, include all bookmarks (score 0, no positions).
if searchTerm == "" {
scoredEntries = append(scoredEntries, scoredEntry{
bookmark: bookmark,
score: 0,
origIndex: i,
})
continue
}
// For bookmarks with a title, Display is set to the title and SubText to the URL.
// We perform fuzzy matching on both fields.
if bookmark.Title != "" {
// Fuzzy match against the title.
candidateTitle := strings.ToLower(bookmark.Title)
textTitle := util.ToChars([]byte(candidateTitle))
resultTitle, titlePositionsPtr := algo.FuzzyMatchV2(false, true, true, &textTitle, patternRunes, true, &slab)
var titleScore int
var titlePositions []int
if titlePositionsPtr != nil {
titlePositions = *titlePositionsPtr
}
titleScore = resultTitle.Score
// Fuzzy match against the URL.
candidateUrl := strings.ToLower(bookmark.Url)
textUrl := util.ToChars([]byte(candidateUrl))
resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab)
var urlScore int
var urlPositions []int
if urlPositionsPtr != nil {
urlPositions = *urlPositionsPtr
}
urlScore = resultUrl.Score
// Compute the overall score as the higher of the two.
maxScore := titleScore
if urlScore > maxScore {
maxScore = urlScore
}
// If neither field produced a positive match, skip this bookmark.
if maxScore <= 0 {
continue
}
// Since Display is title, we use the title match positions as MatchPos and the URL match positions as SubMatchPos.
scoredEntries = append(scoredEntries, scoredEntry{
bookmark: bookmark,
score: maxScore,
matchPos: titlePositions,
subMatchPos: urlPositions,
origIndex: i,
})
} else {
// For bookmarks with no title, Display is set to the URL.
// Only perform fuzzy matching against the URL.
candidateUrl := strings.ToLower(bookmark.Url)
textUrl := util.ToChars([]byte(candidateUrl))
resultUrl, urlPositionsPtr := algo.FuzzyMatchV2(false, true, true, &textUrl, patternRunes, true, &slab)
urlScore := resultUrl.Score
var urlPositions []int
if urlPositionsPtr != nil {
urlPositions = *urlPositionsPtr
}
// Skip this bookmark if the URL doesn't match.
if urlScore <= 0 {
continue
}
scoredEntries = append(scoredEntries, scoredEntry{
bookmark: bookmark,
score: urlScore,
matchPos: urlPositions, // match positions come from the URL, since that's what is displayed.
subMatchPos: nil,
origIndex: i,
})
}
}
// Sort the scored entries in descending order by score.
// For equal scores, preserve the original order from the Bookmarks list.
sort.Slice(scoredEntries, func(i, j int) bool {
if scoredEntries[i].score != scoredEntries[j].score {
return scoredEntries[i].score > scoredEntries[j].score
}
return scoredEntries[i].origIndex < scoredEntries[j].origIndex
})
// Build up to MaxSuggestions suggestions.
var suggestions []wshrpc.SuggestionType
for _, entry := range scoredEntries {
var display, subText string
if entry.bookmark.Title != "" {
display = entry.bookmark.Title
subText = entry.bookmark.Url
} else {
display = entry.bookmark.Url
subText = ""
}
suggestion := wshrpc.SuggestionType{
Type: "url",
SuggestionId: utilfn.QuickHashString(entry.bookmark.Url),
Display: display,
SubText: subText,
MatchPos: entry.matchPos, // These positions correspond to the field in Display.
SubMatchPos: entry.subMatchPos, // For bookmarks with a title, this is the URL match positions.
Score: entry.score,
UrlUrl: entry.bookmark.Url,
}
suggestion.IconSrc = faviconcache.GetFavicon(entry.bookmark.Url)
suggestions = append(suggestions, suggestion)
if len(suggestions) >= MaxSuggestions {
break
}
}
return &wshrpc.FetchSuggestionsResponse{
Suggestions: suggestions,
ReqNum: data.ReqNum,
}, nil
}
// Define a scored entry for fuzzy matching.
type scoredEntry struct {
ent fs.DirEntry
score int
fileName string
positions []int
}
// We'll use a heap to only keep the top MaxSuggestions when a search term is provided.
// Define a min-heap so that the worst (lowest scoring) candidate is at the top.
type scoredEntryHeap []scoredEntry
// Less: lower score is “less”. For equal scores, a candidate with a longer filename is considered worse.
func (h scoredEntryHeap) Len() int { return len(h) }
func (h scoredEntryHeap) Less(i, j int) bool {
if h[i].score != h[j].score {
return h[i].score < h[j].score
}
return len(h[i].fileName) > len(h[j].fileName)
}
func (h scoredEntryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *scoredEntryHeap) Push(x interface{}) { *h = append(*h, x.(scoredEntry)) }
func (h *scoredEntryHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
func fetchFileSuggestions(_ context.Context, data wshrpc.FetchSuggestionsData) (*wshrpc.FetchSuggestionsResponse, error) {
// Only support file suggestions.
if data.SuggestionType != "file" {
return nil, fmt.Errorf("unsupported suggestion type: %q", data.SuggestionType)
}
// Resolve the base directory, query prefix (for display) and search term.
baseDir, queryPrefix, searchTerm, err := resolveFileQuery(data.FileCwd, data.Query)
if err != nil {
return nil, fmt.Errorf("error resolving base dir: %w", err)
}
// Use a cancellable context for directory listing.
listingCtx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
entriesCh, err := listDirectory(listingCtx, baseDir, 1000)
if err != nil {
return nil, fmt.Errorf("error listing directory: %w", err)
}
const maxEntries = MaxSuggestions // top-k entries
// Always use a heap.
var topHeap scoredEntryHeap
heap.Init(&topHeap)
var patternRunes []rune
if searchTerm != "" {
patternRunes = []rune(strings.ToLower(searchTerm))
}
var slab util.Slab
var index int // used for ordering when searchTerm is empty
// Process each directory entry.
for result := range entriesCh {
if result.Err != nil {
return nil, fmt.Errorf("error reading directory: %w", result.Err)
}
de := result.Entry
fileName := de.Name()
var score int
var candidatePositions []int
if searchTerm != "" {
// Perform fuzzy matching.
candidate := strings.ToLower(fileName)
text := util.ToChars([]byte(candidate))
matchResult, positions := algo.FuzzyMatchV2(false, true, true, &text, patternRunes, true, &slab)
if matchResult.Score <= 0 {
index++
continue
}
score = matchResult.Score
if positions != nil {
candidatePositions = *positions
}
} else {
// Use ordering: first entry gets highest score.
score = maxEntries - index
}
index++
se := scoredEntry{
ent: de,
score: score,
fileName: fileName,
positions: candidatePositions,
}
if topHeap.Len() < maxEntries {
heap.Push(&topHeap, se)
} else {
// Replace the worst candidate if this one is better.
worst := topHeap[0]
if se.score > worst.score || (se.score == worst.score && len(se.fileName) < len(worst.fileName)) {
heap.Pop(&topHeap)
heap.Push(&topHeap, se)
}
}
if searchTerm == "" && topHeap.Len() >= maxEntries {
break
}
}
// Extract and sort the scored entries (highest score first).
scoredEntries := make([]scoredEntry, topHeap.Len())
copy(scoredEntries, topHeap)
sort.Slice(scoredEntries, func(i, j int) bool {
if scoredEntries[i].score != scoredEntries[j].score {
return scoredEntries[i].score > scoredEntries[j].score
}
return len(scoredEntries[i].fileName) < len(scoredEntries[j].fileName)
})
// Build suggestions from the scored entries.
var suggestions []wshrpc.SuggestionType
for _, candidate := range scoredEntries {
fileName := candidate.ent.Name()
fullPath := filepath.Join(baseDir, fileName)
suggestionFileName := filepath.Join(queryPrefix, fileName)
offset := len(suggestionFileName) - len(fileName)
if offset > 0 && len(candidate.positions) > 0 {
// Adjust match positions to account for the query prefix.
for j := range candidate.positions {
candidate.positions[j] += offset
}
}
s := wshrpc.SuggestionType{
Type: "file",
FilePath: fullPath,
SuggestionId: utilfn.QuickHashString(fullPath),
Display: suggestionFileName,
FileName: suggestionFileName,
FileMimeType: fileutil.DetectMimeTypeWithDirEnt(fullPath, candidate.ent),
MatchPos: candidate.positions,
Score: candidate.score,
}
suggestions = append(suggestions, s)
if len(suggestions) >= MaxSuggestions {
break
}
}
return &wshrpc.FetchSuggestionsResponse{
Suggestions: suggestions,
ReqNum: data.ReqNum,
}, nil
}