Просмотр исходного кода

Merge pull request #357 from samhvw8/feat/fuzzy-search-file-folder

New Feature fuzzy search in mentions
Matt Rubens 11 месяцев назад
Родитель
Сommit
ef8d02dfe5

+ 5 - 0
.changeset/tiny-snakes-chew.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Improvements to fuzzy search in mentions, history, and model lists

+ 5 - 7
webview-ui/package-lock.json

@@ -18,7 +18,7 @@
 				"@vscode/webview-ui-toolkit": "^1.4.0",
 				"debounce": "^2.1.1",
 				"fast-deep-equal": "^3.1.3",
-				"fuse.js": "^7.0.0",
+				"fzf": "^0.5.2",
 				"react": "^18.3.1",
 				"react-dom": "^18.3.1",
 				"react-remark": "^2.1.0",
@@ -7480,12 +7480,10 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/fuse.js": {
-			"version": "7.0.0",
-			"license": "Apache-2.0",
-			"engines": {
-				"node": ">=10"
-			}
+		"node_modules/fzf": {
+			"version": "0.5.2",
+			"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
+			"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="
 		},
 		"node_modules/gensync": {
 			"version": "1.0.0-beta.2",

+ 1 - 1
webview-ui/package.json

@@ -13,7 +13,7 @@
 		"@vscode/webview-ui-toolkit": "^1.4.0",
 		"debounce": "^2.1.1",
 		"fast-deep-equal": "^3.1.3",
-		"fuse.js": "^7.0.0",
+		"fzf": "^0.5.2",
 		"react": "^18.3.1",
 		"react-dom": "^18.3.1",
 		"react-remark": "^2.1.0",

+ 14 - 120
webview-ui/src/components/history/HistoryView.tsx

@@ -3,8 +3,9 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
 import { Virtuoso } from "react-virtuoso"
 import React, { memo, useMemo, useState, useEffect } from "react"
-import Fuse, { FuseResult } from "fuse.js"
+import { Fzf } from "fzf"
 import { formatLargeNumber } from "../../utils/format"
+import { highlightFzfMatch } from "../../utils/highlight"
 
 type HistoryViewProps = {
 	onDone: () => void
@@ -67,20 +68,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 		return taskHistory.filter((item) => item.ts && item.task)
 	}, [taskHistory])
 
-	const fuse = useMemo(() => {
-		return new Fuse(presentableTasks, {
-			keys: ["task"],
-			threshold: 0.6,
-			shouldSort: true,
-			isCaseSensitive: false,
-			ignoreLocation: false,
-			includeMatches: true,
-			minMatchCharLength: 1,
+	const fzf = useMemo(() => {
+		return new Fzf(presentableTasks, {
+			selector: item => item.task
 		})
 	}, [presentableTasks])
 
 	const taskHistorySearchResults = useMemo(() => {
-		let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
+		let results = presentableTasks
+		if (searchQuery) {
+			const searchResults = fzf.find(searchQuery)
+			results = searchResults.map(result => ({
+				...result.item,
+				task: highlightFzfMatch(result.item.task, Array.from(result.positions))
+			}))
+		}
 
 		// First apply search if needed
 		const searchResults = searchQuery ? results : presentableTasks;
@@ -104,7 +106,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 					return (b.ts || 0) - (a.ts || 0);
 			}
 		});
-	}, [presentableTasks, searchQuery, fuse, sortOption])
+	}, [presentableTasks, searchQuery, fzf, sortOption])
 
 	return (
 		<>
@@ -463,112 +465,4 @@ const ExportButton = ({ itemId }: { itemId: string }) => (
 	</VSCodeButton>
 )
 
-// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
-export const highlight = (
-	fuseSearchResult: FuseResult<any>[],
-	highlightClassName: string = "history-item-highlight",
-) => {
-	const set = (obj: Record<string, any>, path: string, value: any) => {
-		const pathValue = path.split(".")
-		let i: number
-
-		for (i = 0; i < pathValue.length - 1; i++) {
-			if (pathValue[i] === "__proto__" || pathValue[i] === "constructor") return
-			obj = obj[pathValue[i]] as Record<string, any>
-		}
-
-		if (pathValue[i] !== "__proto__" && pathValue[i] !== "constructor") {
-			obj[pathValue[i]] = value
-		}
-	}
-
-	// Function to merge overlapping regions
-	const mergeRegions = (regions: [number, number][]): [number, number][] => {
-		if (regions.length === 0) return regions
-
-		// Sort regions by start index
-		regions.sort((a, b) => a[0] - b[0])
-
-		const merged: [number, number][] = [regions[0]]
-
-		for (let i = 1; i < regions.length; i++) {
-			const last = merged[merged.length - 1]
-			const current = regions[i]
-
-			if (current[0] <= last[1] + 1) {
-				// Overlapping or adjacent regions
-				last[1] = Math.max(last[1], current[1])
-			} else {
-				merged.push(current)
-			}
-		}
-
-		return merged
-	}
-
-	const generateHighlightedText = (inputText: string, regions: [number, number][] = []) => {
-		if (regions.length === 0) {
-			return inputText
-		}
-	
-		// Sort and merge overlapping regions
-		const mergedRegions = mergeRegions(regions)
-	
-		// Convert regions to a list of parts with their highlight status
-		const parts: { text: string; highlight: boolean }[] = []
-		let lastIndex = 0
-	
-		mergedRegions.forEach(([start, end]) => {
-			// Add non-highlighted text before this region
-			if (start > lastIndex) {
-				parts.push({
-					text: inputText.substring(lastIndex, start),
-					highlight: false
-				})
-			}
-	
-			// Add highlighted text
-			parts.push({
-				text: inputText.substring(start, end + 1),
-				highlight: true
-			})
-	
-			lastIndex = end + 1
-		})
-	
-		// Add any remaining text
-		if (lastIndex < inputText.length) {
-			parts.push({
-				text: inputText.substring(lastIndex),
-				highlight: false
-			})
-		}
-	
-		// Build final string
-		return parts
-			.map(part =>
-				part.highlight
-					? `<span class="${highlightClassName}">${part.text}</span>`
-					: part.text
-			)
-			.join('')
-	}
-
-	return fuseSearchResult
-		.filter(({ matches }) => matches && matches.length)
-		.map(({ item, matches }) => {
-			const highlightedItem = { ...item }
-
-			matches?.forEach((match) => {
-				if (match.key && typeof match.value === "string" && match.indices) {
-					// Merge overlapping regions before generating highlighted text
-					const mergedIndices = mergeRegions([...match.indices])
-					set(highlightedItem, match.key, generateHighlightedText(match.value, mergedIndices))
-				}
-			})
-
-			return highlightedItem
-		})
-}
-
 export default memo(HistoryView)

+ 13 - 17
webview-ui/src/components/settings/GlamaModelPicker.tsx

@@ -1,5 +1,5 @@
 import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import Fuse from "fuse.js"
+import { Fzf } from "fzf"
 import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
 import { useRemark } from "react-remark"
 import { useMount } from "react-use"
@@ -7,7 +7,7 @@ import styled from "styled-components"
 import { glamaDefaultModelId } from "../../../../src/shared/api"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
-import { highlight } from "../history/HistoryView"
+import { highlightFzfMatch } from "../../utils/highlight"
 import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
 
 const GlamaModelPicker: React.FC = () => {
@@ -72,25 +72,21 @@ const GlamaModelPicker: React.FC = () => {
 		}))
 	}, [modelIds])
 
-	const fuse = useMemo(() => {
-		return new Fuse(searchableItems, {
-			keys: ["html"], // highlight function will update this
-			threshold: 0.6,
-			shouldSort: true,
-			isCaseSensitive: false,
-			ignoreLocation: false,
-			includeMatches: true,
-			minMatchCharLength: 1,
+	const fzf = useMemo(() => {
+		return new Fzf(searchableItems, {
+			selector: item => item.html
 		})
 	}, [searchableItems])
 
 	const modelSearchResults = useMemo(() => {
-		let results: { id: string; html: string }[] = searchTerm
-			? highlight(fuse.search(searchTerm), "model-item-highlight")
-			: searchableItems
-		// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
-		return results
-	}, [searchableItems, searchTerm, fuse])
+		if (!searchTerm) return searchableItems
+
+		const searchResults = fzf.find(searchTerm)
+		return searchResults.map(result => ({
+			...result.item,
+			html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
+		}))
+	}, [searchableItems, searchTerm, fzf])
 
 	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
 		if (!isDropdownVisible) return

+ 13 - 17
webview-ui/src/components/settings/OpenAiModelPicker.tsx

@@ -1,11 +1,11 @@
 import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import Fuse from "fuse.js"
+import { Fzf } from "fzf"
 import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
 import { useRemark } from "react-remark"
 import styled from "styled-components"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
-import { highlight } from "../history/HistoryView"
+import { highlightFzfMatch } from "../../utils/highlight"
 
 const OpenAiModelPicker: React.FC = () => {
 	const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
@@ -71,25 +71,21 @@ const OpenAiModelPicker: React.FC = () => {
 		}))
 	}, [modelIds])
 
-	const fuse = useMemo(() => {
-		return new Fuse(searchableItems, {
-			keys: ["html"], // highlight function will update this
-			threshold: 0.6,
-			shouldSort: true,
-			isCaseSensitive: false,
-			ignoreLocation: false,
-			includeMatches: true,
-			minMatchCharLength: 1,
+	const fzf = useMemo(() => {
+		return new Fzf(searchableItems, {
+			selector: item => item.html
 		})
 	}, [searchableItems])
 
 	const modelSearchResults = useMemo(() => {
-		let results: { id: string; html: string }[] = searchTerm
-			? highlight(fuse.search(searchTerm), "model-item-highlight")
-			: searchableItems
-		// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
-		return results
-	}, [searchableItems, searchTerm, fuse])
+		if (!searchTerm) return searchableItems
+
+		const searchResults = fzf.find(searchTerm)
+		return searchResults.map(result => ({
+			...result.item,
+			html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
+		}))
+	}, [searchableItems, searchTerm, fzf])
 
 	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
 		if (!isDropdownVisible) return

+ 13 - 17
webview-ui/src/components/settings/OpenRouterModelPicker.tsx

@@ -1,5 +1,5 @@
 import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import Fuse from "fuse.js"
+import { Fzf } from "fzf"
 import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
 import { useRemark } from "react-remark"
 import { useMount } from "react-use"
@@ -7,7 +7,7 @@ import styled from "styled-components"
 import { openRouterDefaultModelId } from "../../../../src/shared/api"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
-import { highlight } from "../history/HistoryView"
+import { highlightFzfMatch } from "../../utils/highlight"
 import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
 
 const OpenRouterModelPicker: React.FC = () => {
@@ -71,25 +71,21 @@ const OpenRouterModelPicker: React.FC = () => {
 		}))
 	}, [modelIds])
 
-	const fuse = useMemo(() => {
-		return new Fuse(searchableItems, {
-			keys: ["html"], // highlight function will update this
-			threshold: 0.6,
-			shouldSort: true,
-			isCaseSensitive: false,
-			ignoreLocation: false,
-			includeMatches: true,
-			minMatchCharLength: 1,
+	const fzf = useMemo(() => {
+		return new Fzf(searchableItems, {
+			selector: item => item.html
 		})
 	}, [searchableItems])
 
 	const modelSearchResults = useMemo(() => {
-		let results: { id: string; html: string }[] = searchTerm
-			? highlight(fuse.search(searchTerm), "model-item-highlight")
-			: searchableItems
-		// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
-		return results
-	}, [searchableItems, searchTerm, fuse])
+		if (!searchTerm) return searchableItems
+
+		const searchResults = fzf.find(searchTerm)
+		return searchResults.map(result => ({
+			...result.item,
+			html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
+		}))
+	}, [searchableItems, searchTerm, fzf])
 
 	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
 		if (!isDropdownVisible) return

+ 27 - 7
webview-ui/src/utils/context-mentions.ts

@@ -1,4 +1,5 @@
 import { mentionRegex } from "../../../src/shared/context-mentions"
+import { Fzf } from "fzf"
 
 export function insertMention(
 	text: string,
@@ -147,13 +148,21 @@ export function getContextMenuOptions(
 		}
 	}
 
-	// Get matching items, separating by type
-	const matchingItems = queryItems.filter((item) =>
-		item.value?.toLowerCase().includes(lowerQuery) ||
-		item.label?.toLowerCase().includes(lowerQuery) ||
-		item.description?.toLowerCase().includes(lowerQuery)
-	)
+	// Create searchable strings array for fzf
+	const searchableItems = queryItems.map(item => ({
+		original: item,
+		searchStr: [item.value, item.label, item.description].filter(Boolean).join(' ')
+	}))
+
+	// Initialize fzf instance for fuzzy search
+	const fzf = new Fzf(searchableItems, {
+		selector: item => item.searchStr
+	})
 
+	// Get fuzzy matching items
+	const matchingItems = query ? fzf.find(query).map(result => result.item.original) : []
+
+	// Separate matches by type
 	const fileMatches = matchingItems.filter(item =>
 		item.type === ContextMenuOptionType.File ||
 		item.type === ContextMenuOptionType.Folder
@@ -169,7 +178,18 @@ export function getContextMenuOptions(
 
 	// Combine suggestions with matching items in the desired order
 	if (suggestions.length > 0 || matchingItems.length > 0) {
-		return [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
+		const allItems = [...suggestions, ...fileMatches, ...gitMatches, ...otherMatches]
+		
+		// Remove duplicates based on type and value
+		const seen = new Set()
+		const deduped = allItems.filter(item => {
+			const key = `${item.type}-${item.value}`
+			if (seen.has(key)) return false
+			seen.add(key)
+			return true
+		})
+		
+		return deduped
 	}
 
 	return [{ type: ContextMenuOptionType.NoResults }]

+ 44 - 0
webview-ui/src/utils/highlight.ts

@@ -0,0 +1,44 @@
+export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "history-item-highlight") {
+	if (!positions.length) return text
+
+	const parts: { text: string; highlight: boolean }[] = []
+	let lastIndex = 0
+
+	// Sort positions to ensure we process them in order
+	positions.sort((a, b) => a - b)
+
+	positions.forEach((pos) => {
+		// Add non-highlighted text before this position
+		if (pos > lastIndex) {
+			parts.push({
+				text: text.substring(lastIndex, pos),
+				highlight: false
+			})
+		}
+
+		// Add highlighted character
+		parts.push({
+			text: text[pos],
+			highlight: true
+		})
+
+		lastIndex = pos + 1
+	})
+
+	// Add any remaining text
+	if (lastIndex < text.length) {
+		parts.push({
+			text: text.substring(lastIndex),
+			highlight: false
+		})
+	}
+
+	// Build final string
+	return parts
+		.map(part =>
+			part.highlight
+				? `<span class="${highlightClassName}">${part.text}</span>`
+				: part.text
+		)
+		.join('')
+}