diff --git a/.changeset/tiny-snakes-chew.md b/.changeset/tiny-snakes-chew.md new file mode 100644 index 0000000..f2abad3 --- /dev/null +++ b/.changeset/tiny-snakes-chew.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Improvements to fuzzy search in mentions, history, and model lists diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 7c0edd7..781a331 100644 --- a/webview-ui/package-lock.json +++ b/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", diff --git a/webview-ui/package.json b/webview-ui/package.json index ca804ba..6b4d192 100644 --- a/webview-ui/package.json +++ b/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", diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 28ff461..82b6d2f 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/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 }) => ( ) -// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0 -export const highlight = ( - fuseSearchResult: FuseResult[], - highlightClassName: string = "history-item-highlight", -) => { - const set = (obj: Record, 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 - } - - 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 - ? `${part.text}` - : 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) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index fcc3269..de93b9c 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/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) => { if (!isDropdownVisible) return diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 1e192ed..7e8a81f 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/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) => { if (!isDropdownVisible) return diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index b8ba201..f164fb3 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/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) => { if (!isDropdownVisible) return diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 6d846bf..0ac49f9 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/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 }] diff --git a/webview-ui/src/utils/highlight.ts b/webview-ui/src/utils/highlight.ts new file mode 100644 index 0000000..a6bbf76 --- /dev/null +++ b/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 + ? `${part.text}` + : part.text + ) + .join('') +} \ No newline at end of file