Merge remote-tracking branch 'origin/main' into vscode-lm-provider

This commit is contained in:
Matt Rubens
2025-01-15 09:18:55 -05:00
9 changed files with 135 additions and 186 deletions

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,9 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode" import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso" import { Virtuoso } from "react-virtuoso"
import React, { memo, useMemo, useState, useEffect } from "react" import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js" import { Fzf } from "fzf"
import { formatLargeNumber } from "../../utils/format" import { formatLargeNumber } from "../../utils/format"
import { highlightFzfMatch } from "../../utils/highlight"
type HistoryViewProps = { type HistoryViewProps = {
onDone: () => void onDone: () => void
@@ -67,20 +68,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return taskHistory.filter((item) => item.ts && item.task) return taskHistory.filter((item) => item.ts && item.task)
}, [taskHistory]) }, [taskHistory])
const fuse = useMemo(() => { const fzf = useMemo(() => {
return new Fuse(presentableTasks, { return new Fzf(presentableTasks, {
keys: ["task"], selector: item => item.task
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
}) })
}, [presentableTasks]) }, [presentableTasks])
const taskHistorySearchResults = useMemo(() => { 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 // First apply search if needed
const searchResults = searchQuery ? results : presentableTasks; const searchResults = searchQuery ? results : presentableTasks;
@@ -104,7 +106,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
return (b.ts || 0) - (a.ts || 0); return (b.ts || 0) - (a.ts || 0);
} }
}); });
}, [presentableTasks, searchQuery, fuse, sortOption]) }, [presentableTasks, searchQuery, fzf, sortOption])
return ( return (
<> <>
@@ -463,112 +465,4 @@ const ExportButton = ({ itemId }: { itemId: string }) => (
</VSCodeButton> </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) export default memo(HistoryView)

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { mentionRegex } from "../../../src/shared/context-mentions" import { mentionRegex } from "../../../src/shared/context-mentions"
import { Fzf } from "fzf"
export function insertMention( export function insertMention(
text: string, text: string,
@@ -147,13 +148,21 @@ export function getContextMenuOptions(
} }
} }
// Get matching items, separating by type // Create searchable strings array for fzf
const matchingItems = queryItems.filter((item) => const searchableItems = queryItems.map(item => ({
item.value?.toLowerCase().includes(lowerQuery) || original: item,
item.label?.toLowerCase().includes(lowerQuery) || searchStr: [item.value, item.label, item.description].filter(Boolean).join(' ')
item.description?.toLowerCase().includes(lowerQuery) }))
)
// 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 => const fileMatches = matchingItems.filter(item =>
item.type === ContextMenuOptionType.File || item.type === ContextMenuOptionType.File ||
item.type === ContextMenuOptionType.Folder item.type === ContextMenuOptionType.Folder
@@ -169,7 +178,18 @@ export function getContextMenuOptions(
// Combine suggestions with matching items in the desired order // Combine suggestions with matching items in the desired order
if (suggestions.length > 0 || matchingItems.length > 0) { 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 }] return [{ type: ContextMenuOptionType.NoResults }]

View File

@@ -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('')
}