From 89e119f12181b82fafa4bdc17c85480cbc38b1be Mon Sep 17 00:00:00 2001 From: sam hoang Date: Wed, 15 Jan 2025 01:23:18 +0700 Subject: [PATCH 1/6] feat(mentions): implement fuzzy search for file/folder mentions Adds fuzzy search functionality using Fuse.js to improve file and folder search in mentions. --- webview-ui/src/utils/context-mentions.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 6d846bf..7ee90b3 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 Fuse from "fuse.js" 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) - ) + // Initialize Fuse instance for fuzzy search + const fuse = new Fuse(queryItems, { + keys: ["value", "label", "description"], + threshold: 0.6, + shouldSort: true, + isCaseSensitive: false, + ignoreLocation: false, + minMatchCharLength: 1, + }) + // Get fuzzy matching items + const fuseResults = query ? fuse.search(query) : [] + const matchingItems = fuseResults.map(result => result.item) + + // Separate matches by type const fileMatches = matchingItems.filter(item => item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.Folder From 45a7566e6aa1c48fe489db7e19d6e0fbc202087b Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 02:19:17 -0500 Subject: [PATCH 2/6] Use fzf instead of fuse --- webview-ui/package-lock.json | 6 ++++++ webview-ui/package.json | 1 + webview-ui/src/utils/context-mentions.ts | 22 +++++++++++----------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index cdaf3b4..ba3edab 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -19,6 +19,7 @@ "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", @@ -7475,6 +7476,11 @@ "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", "license": "MIT", diff --git a/webview-ui/package.json b/webview-ui/package.json index 3ce9533..e597544 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -14,6 +14,7 @@ "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/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 7ee90b3..0e24b4a 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -1,5 +1,5 @@ import { mentionRegex } from "../../../src/shared/context-mentions" -import Fuse from "fuse.js" +import { Fzf } from "fzf" export function insertMention( text: string, @@ -148,19 +148,19 @@ export function getContextMenuOptions( } } - // Initialize Fuse instance for fuzzy search - const fuse = new Fuse(queryItems, { - keys: ["value", "label", "description"], - threshold: 0.6, - shouldSort: true, - isCaseSensitive: false, - ignoreLocation: false, - minMatchCharLength: 1, + // 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 fuseResults = query ? fuse.search(query) : [] - const matchingItems = fuseResults.map(result => result.item) + const matchingItems = query ? fzf.find(query).map(result => result.item.original) : [] // Separate matches by type const fileMatches = matchingItems.filter(item => From 966ac5ecba96ead68484e7170b74cfc5b5043648 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 02:30:13 -0500 Subject: [PATCH 3/6] Remove duplicates --- webview-ui/src/utils/context-mentions.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 0e24b4a..0ac49f9 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -178,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 }] From ff9b8c33e188540836d2c43e9d19688570af2014 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 02:50:52 -0500 Subject: [PATCH 4/6] Remove all other usages of fuse.js --- webview-ui/package-lock.json | 8 - webview-ui/package.json | 1 - .../src/components/history/HistoryView.tsx | 156 ++++++------------ .../components/settings/GlamaModelPicker.tsx | 74 +++++++-- .../components/settings/OpenAiModelPicker.tsx | 74 +++++++-- .../settings/OpenRouterModelPicker.tsx | 74 +++++++-- 6 files changed, 218 insertions(+), 169 deletions(-) diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 5203e5a..781a331 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -18,7 +18,6 @@ "@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", @@ -7481,13 +7480,6 @@ "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", diff --git a/webview-ui/package.json b/webview-ui/package.json index 3cb9a31..6b4d192 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -13,7 +13,6 @@ "@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", diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 28ff461..13150f9 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -3,7 +3,7 @@ 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" type HistoryViewProps = { @@ -67,20 +67,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 +105,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { return (b.ts || 0) - (a.ts || 0); } }); - }, [presentableTasks, searchQuery, fuse, sortOption]) + }, [presentableTasks, searchQuery, fzf, sortOption]) return ( <> @@ -463,112 +464,49 @@ 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 +const highlightFzfMatch = (text: string, positions: number[], highlightClassName: string = "history-item-highlight") => { + if (!positions.length) return text - for (i = 0; i < pathValue.length - 1; i++) { - if (pathValue[i] === "__proto__" || pathValue[i] === "constructor") return - obj = obj[pathValue[i]] as Record - } + const parts: { text: string; highlight: boolean }[] = [] + let lastIndex = 0 - if (pathValue[i] !== "__proto__" && pathValue[i] !== "constructor") { - obj[pathValue[i]] = value - } - } + // Sort positions to ensure we process them in order + positions.sort((a, b) => a - b) - // 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 + positions.forEach((pos) => { + // Add non-highlighted text before this position + if (pos > lastIndex) { 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), + text: text.substring(lastIndex, pos), highlight: false }) } - - // Build final string - return parts - .map(part => - part.highlight - ? `${part.text}` - : part.text - ) - .join('') + + // 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 + }) } - 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 - }) + // Build final string + return parts + .map(part => + part.highlight + ? `${part.text}` + : part.text + ) + .join('') } export default memo(HistoryView) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index fcc3269..5164ba5 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,6 @@ 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 { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const GlamaModelPicker: React.FC = () => { @@ -72,25 +71,66 @@ 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 highlightFzfMatch = (text: string, positions: number[]) => { + 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('') + } + + 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)) + })) + }, [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..2e674dd 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -1,11 +1,10 @@ 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" const OpenAiModelPicker: React.FC = () => { const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() @@ -71,25 +70,66 @@ 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 highlightFzfMatch = (text: string, positions: number[]) => { + 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('') + } + + 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)) + })) + }, [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..13e308d 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,6 @@ 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 { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const OpenRouterModelPicker: React.FC = () => { @@ -71,25 +70,66 @@ 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 highlightFzfMatch = (text: string, positions: number[]) => { + 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('') + } + + 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)) + })) + }, [searchableItems, searchTerm, fzf]) const handleKeyDown = (event: KeyboardEvent) => { if (!isDropdownVisible) return From 5fa0272b4976ca62ebf5b5aadd3e652431349517 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 02:57:33 -0500 Subject: [PATCH 5/6] DRY up highlighting code --- .../src/components/history/HistoryView.tsx | 46 +----------------- .../components/settings/GlamaModelPicker.tsx | 48 +------------------ .../components/settings/OpenAiModelPicker.tsx | 48 +------------------ .../settings/OpenRouterModelPicker.tsx | 48 +------------------ webview-ui/src/utils/highlight.ts | 44 +++++++++++++++++ 5 files changed, 51 insertions(+), 183 deletions(-) create mode 100644 webview-ui/src/utils/highlight.ts diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 13150f9..82b6d2f 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -5,6 +5,7 @@ import { Virtuoso } from "react-virtuoso" import React, { memo, useMemo, useState, useEffect } from "react" import { Fzf } from "fzf" import { formatLargeNumber } from "../../utils/format" +import { highlightFzfMatch } from "../../utils/highlight" type HistoryViewProps = { onDone: () => void @@ -464,49 +465,4 @@ const ExportButton = ({ itemId }: { itemId: string }) => ( ) -const 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('') -} - export default memo(HistoryView) diff --git a/webview-ui/src/components/settings/GlamaModelPicker.tsx b/webview-ui/src/components/settings/GlamaModelPicker.tsx index 5164ba5..de93b9c 100644 --- a/webview-ui/src/components/settings/GlamaModelPicker.tsx +++ b/webview-ui/src/components/settings/GlamaModelPicker.tsx @@ -7,6 +7,7 @@ import styled from "styled-components" import { glamaDefaultModelId } from "../../../../src/shared/api" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" +import { highlightFzfMatch } from "../../utils/highlight" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const GlamaModelPicker: React.FC = () => { @@ -71,51 +72,6 @@ const GlamaModelPicker: React.FC = () => { })) }, [modelIds]) - const highlightFzfMatch = (text: string, positions: number[]) => { - 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('') - } - const fzf = useMemo(() => { return new Fzf(searchableItems, { selector: item => item.html @@ -128,7 +84,7 @@ const GlamaModelPicker: React.FC = () => { const searchResults = fzf.find(searchTerm) return searchResults.map(result => ({ ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions)) + html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight") })) }, [searchableItems, searchTerm, fzf]) diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index 2e674dd..7e8a81f 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -5,6 +5,7 @@ import { useRemark } from "react-remark" import styled from "styled-components" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" +import { highlightFzfMatch } from "../../utils/highlight" const OpenAiModelPicker: React.FC = () => { const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() @@ -70,51 +71,6 @@ const OpenAiModelPicker: React.FC = () => { })) }, [modelIds]) - const highlightFzfMatch = (text: string, positions: number[]) => { - 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('') - } - const fzf = useMemo(() => { return new Fzf(searchableItems, { selector: item => item.html @@ -127,7 +83,7 @@ const OpenAiModelPicker: React.FC = () => { const searchResults = fzf.find(searchTerm) return searchResults.map(result => ({ ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions)) + html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight") })) }, [searchableItems, searchTerm, fzf]) diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index 13e308d..f164fb3 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -7,6 +7,7 @@ import styled from "styled-components" import { openRouterDefaultModelId } from "../../../../src/shared/api" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" +import { highlightFzfMatch } from "../../utils/highlight" import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" const OpenRouterModelPicker: React.FC = () => { @@ -70,51 +71,6 @@ const OpenRouterModelPicker: React.FC = () => { })) }, [modelIds]) - const highlightFzfMatch = (text: string, positions: number[]) => { - 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('') - } - const fzf = useMemo(() => { return new Fzf(searchableItems, { selector: item => item.html @@ -127,7 +83,7 @@ const OpenRouterModelPicker: React.FC = () => { const searchResults = fzf.find(searchTerm) return searchResults.map(result => ({ ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions)) + html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight") })) }, [searchableItems, searchTerm, fzf]) 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 From cc077832db6c0823470d9e0ba6ec2d5d74458579 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 15 Jan 2025 09:13:12 -0500 Subject: [PATCH 6/6] Release --- .changeset/tiny-snakes-chew.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tiny-snakes-chew.md 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