Move context-mention file/folder search to the server (#1824)

This commit is contained in:
Matt Rubens 2025-03-20 00:45:27 -04:00 committed by GitHub
parent 499b8e4665
commit f181da1454
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 362 additions and 90 deletions

6
package-lock.json generated
View file

@ -32,6 +32,7 @@
"diff-match-patch": "^1.0.5",
"fast-deep-equal": "^3.1.3",
"fastest-levenshtein": "^1.0.16",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"i18next": "^24.2.2",
@ -9326,6 +9327,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/gauge": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.2.tgz",

View file

@ -345,6 +345,7 @@
"diff-match-patch": "^1.0.5",
"fast-deep-equal": "^3.1.3",
"fastest-levenshtein": "^1.0.16",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"i18next": "^24.2.2",

View file

@ -39,6 +39,7 @@ import { McpServerManager } from "../../services/mcp/McpServerManager"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { fileExistsAtPath } from "../../utils/fs"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
@ -1750,6 +1751,46 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
}
break
}
case "searchFiles": {
const workspacePath = getWorkspacePath()
if (!workspacePath) {
// Handle case where workspace path is not available
await this.postMessageToWebview({
type: "fileSearchResults",
results: [],
requestId: message.requestId,
error: "No workspace path available",
})
break
}
try {
// Call file search service with query from message
const results = await searchWorkspaceFiles(
message.query || "",
workspacePath,
20, // Use default limit, as filtering is now done in the backend
)
// Send results back to webview
await this.postMessageToWebview({
type: "fileSearchResults",
results,
requestId: message.requestId,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
// Send error response to webview
await this.postMessageToWebview({
type: "fileSearchResults",
results: [],
error: errorMessage,
requestId: message.requestId,
})
}
break
}
case "saveApiConfiguration":
if (message.text && message.apiConfiguration) {
try {

View file

@ -4,6 +4,7 @@ import * as path from "path"
import * as fs from "fs"
import * as readline from "readline"
import { RooIgnoreController } from "../../core/ignore/RooIgnoreController"
import { fileExistsAtPath } from "../../utils/fs"
/*
This file provides functionality to perform regex searches on files using ripgrep.
Inspired by: https://github.com/DiscreteTom/vscode-ripgrep-utils
@ -71,11 +72,13 @@ const MAX_LINE_LENGTH = 500
export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH): string {
return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
}
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
/**
* Get the path to the ripgrep binary within the VSCode installation
*/
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
return (await pathExists(fullPath)) ? fullPath : undefined
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
}
return (
@ -86,14 +89,6 @@ async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
)
}
async function pathExists(path: string): Promise<boolean> {
return new Promise((resolve) => {
fs.access(path, (err) => {
resolve(err === null)
})
})
}
async function execRipgrep(bin: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const rgProcess = childProcess.spawn(bin, args)

View file

@ -0,0 +1,125 @@
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs"
import * as childProcess from "child_process"
import * as readline from "readline"
import { Fzf } from "fzf"
import { getBinPath } from "../ripgrep"
async function executeRipgrepForFiles(
rgPath: string,
workspacePath: string,
limit: number = 5000,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
return new Promise((resolve, reject) => {
const args = [
"--files",
"--follow",
"-g",
"!**/node_modules/**",
"-g",
"!**/.git/**",
"-g",
"!**/out/**",
"-g",
"!**/dist/**",
workspacePath,
]
const rgProcess = childProcess.spawn(rgPath, args)
const rl = readline.createInterface({
input: rgProcess.stdout,
crlfDelay: Infinity,
})
const results: { path: string; type: "file" | "folder"; label?: string }[] = []
let count = 0
rl.on("line", (line) => {
if (count < limit) {
try {
const relativePath = path.relative(workspacePath, line)
results.push({
path: relativePath,
type: "file",
label: path.basename(relativePath),
})
count++
} catch (error) {
// Silently ignore errors processing individual paths
}
} else {
rl.close()
rgProcess.kill()
}
})
let errorOutput = ""
rgProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
rl.on("close", () => {
if (errorOutput && results.length === 0) {
reject(new Error(`ripgrep process error: ${errorOutput}`))
} else {
resolve(results)
}
})
rgProcess.on("error", (error) => {
reject(new Error(`ripgrep process error: ${error.message}`))
})
})
}
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit: number = 20,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
try {
const vscodeAppRoot = vscode.env.appRoot
const rgPath = await getBinPath(vscodeAppRoot)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
const allFiles = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
if (!query.trim()) {
return allFiles.slice(0, limit)
}
const searchItems = allFiles.map((file) => ({
original: file,
searchStr: `${file.path} ${file.label || ""}`,
}))
const fzf = new Fzf(searchItems, {
selector: (item) => item.searchStr,
})
const results = fzf
.find(query)
.slice(0, limit)
.map((result) => result.item.original)
const resultsWithDirectoryCheck = await Promise.all(
results.map(async (result) => {
const fullPath = path.join(workspacePath, result.path)
const isDirectory = fs.existsSync(fullPath) && fs.lstatSync(fullPath).isDirectory()
return {
...result,
type: isDirectory ? ("folder" as const) : ("file" as const),
}
}),
)
return resultsWithDirectoryCheck
} catch (error) {
return []
}
}

View file

@ -56,6 +56,7 @@ export interface ExtensionMessage {
| "remoteBrowserEnabled"
| "ttsStart"
| "ttsStop"
| "fileSearchResults"
text?: string
action?:
| "chatButtonClicked"
@ -92,6 +93,12 @@ export interface ExtensionMessage {
values?: Record<string, any>
requestId?: string
promptText?: string
results?: Array<{
path: string
type: "file" | "folder"
label?: string
}>
error?: string
}
export interface ApiConfigMeta {

View file

@ -114,6 +114,7 @@ export interface WebviewMessage {
| "browserConnectionResult"
| "remoteBrowserEnabled"
| "language"
| "searchFiles"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse

View file

@ -16,6 +16,7 @@ import {
insertMention,
removeMention,
shouldShowContextMenu,
SearchResult,
} from "@/utils/context-mentions"
import { convertToMentionPath } from "@/utils/path-mentions"
import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui"
@ -64,6 +65,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState()
const [gitCommits, setGitCommits] = useState<any[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchRequestId, setSearchRequestId] = useState<string>("")
// Close dropdown when clicking outside.
useEffect(() => {
@ -76,7 +80,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showDropdown])
// Handle enhanced prompt response.
// Handle enhanced prompt response and search results.
useEffect(() => {
const messageHandler = (event: MessageEvent) => {
const message = event.data
@ -97,12 +101,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}))
setGitCommits(commits)
} else if (message.type === "fileSearchResults") {
setSearchLoading(false)
if (message.requestId === searchRequestId) {
setFileSearchResults(message.results || [])
}
}
}
window.addEventListener("message", messageHandler)
return () => window.removeEventListener("message", messageHandler)
}, [setInputValue])
}, [setInputValue, searchRequestId])
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@ -275,6 +284,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
searchQuery,
selectedType,
queryItems,
fileSearchResults,
getAllModes(customModes),
)
const optionsLength = options.length
@ -310,6 +320,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
searchQuery,
selectedType,
queryItems,
fileSearchResults,
getAllModes(customModes),
)[selectedMenuIndex]
if (
@ -378,6 +389,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
justDeletedSpaceAfterMention,
queryItems,
customModes,
fileSearchResults,
],
)
@ -387,6 +399,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setIntendedCursorPosition(null) // Reset the state.
}
}, [inputValue, intendedCursorPosition])
// Ref to store the search timeout
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -408,8 +422,32 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
setSearchQuery(query)
// Send file search request if query is not empty
if (query.length > 0) {
setSelectedMenuIndex(0)
// Don't clear results until we have new ones
// This prevents flickering
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a timeout to debounce the search requests
searchTimeoutRef.current = setTimeout(() => {
// Generate a request ID for this search
const reqId = Math.random().toString(36).substring(2, 9)
setSearchRequestId(reqId)
setSearchLoading(true)
// Send message to extension to search files
vscode.postMessage({
type: "searchFiles",
query: query,
requestId: reqId,
})
}, 200) // 200ms debounce
} else {
setSelectedMenuIndex(3) // Set to "File" option by default
}
@ -417,9 +455,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} else {
setSearchQuery("")
setSelectedMenuIndex(-1)
setFileSearchResults([]) // Clear file search results
}
},
[setInputValue],
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading],
)
useEffect(() => {
@ -675,6 +714,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
selectedType={selectedType}
queryItems={queryItems}
modes={getAllModes(customModes)}
loading={searchLoading}
dynamicSearchResults={fileSearchResults}
/>
</div>
)}

View file

@ -1,5 +1,10 @@
import React, { useEffect, useMemo, useRef } from "react"
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions"
import {
ContextMenuOptionType,
ContextMenuQueryItem,
getContextMenuOptions,
SearchResult,
} from "../../utils/context-mentions"
import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import { ModeConfig } from "../../../../src/shared/modes"
@ -12,6 +17,8 @@ interface ContextMenuProps {
selectedType: ContextMenuOptionType | null
queryItems: ContextMenuQueryItem[]
modes?: ModeConfig[]
loading?: boolean // New loading prop
dynamicSearchResults?: SearchResult[] // New dynamic search results prop
}
const ContextMenu: React.FC<ContextMenuProps> = ({
@ -23,13 +30,14 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
selectedType,
queryItems,
modes,
loading = false,
dynamicSearchResults = [],
}) => {
const menuRef = useRef<HTMLDivElement>(null)
const filteredOptions = useMemo(
() => getContextMenuOptions(searchQuery, selectedType, queryItems, modes),
[searchQuery, selectedType, queryItems, modes],
)
const filteredOptions = useMemo(() => {
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes)
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes])
useEffect(() => {
if (menuRef.current) {
@ -175,71 +183,85 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
maxHeight: "200px",
overflowY: "auto",
}}>
{filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
style={{
padding: "8px 12px",
cursor: isOptionSelectable(option) ? "pointer" : "default",
color: "var(--vscode-dropdown-foreground)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
...(index === selectedIndex && isOptionSelectable(option)
? {
backgroundColor: "var(--vscode-list-activeSelectionBackground)",
color: "var(--vscode-list-activeSelectionForeground)",
}
: {}),
}}
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
{filteredOptions && filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
style={{
padding: "8px 12px",
cursor: isOptionSelectable(option) ? "pointer" : "default",
color: "var(--vscode-dropdown-foreground)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 0,
overflow: "hidden",
paddingTop: 0,
}}>
{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
justifyContent: "space-between",
...(index === selectedIndex && isOptionSelectable(option)
? {
backgroundColor: "var(--vscode-list-activeSelectionBackground)",
color: "var(--vscode-list-activeSelectionForeground)",
}
: {}),
}}
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
<div
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 0,
overflow: "hidden",
paddingTop: 0,
}}>
{option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && (
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0,
}}
/>
)}
{renderOptionContent(option)}
</div>
{(option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{(option.type === ContextMenuOptionType.Problems ||
option.type === ContextMenuOptionType.Terminal ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.OpenedFile ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{
marginRight: "6px",
flexShrink: 0,
fontSize: "14px",
marginTop: 0,
}}
/>
)}
{renderOptionContent(option)}
</div>
{(option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.Git) &&
!option.value && (
<i
className="codicon codicon-chevron-right"
className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
{(option.type === ContextMenuOptionType.Problems ||
option.type === ContextMenuOptionType.Terminal ||
((option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder ||
option.type === ContextMenuOptionType.OpenedFile ||
option.type === ContextMenuOptionType.Git) &&
option.value)) && (
<i
className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
/>
)}
</div>
))
) : (
<div
style={{
padding: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--vscode-foreground)",
opacity: 0.7,
}}>
<span>No results found</span>
</div>
))}
)}
</div>
</div>
)

View file

@ -131,8 +131,8 @@ describe("shouldShowContextMenu", () => {
expect(shouldShowContextMenu("Hello @http://test.com", 17)).toBe(false)
})
it("should return false for @problems", () => {
it("should return true for @problems", () => {
// Position cursor at the end to test the full word
expect(shouldShowContextMenu("@problems", 9)).toBe(false)
expect(shouldShowContextMenu("@problems", 9)).toBe(true)
})
})

View file

@ -1,7 +1,13 @@
import { mentionRegex } from "../../../src/shared/context-mentions"
import { Fzf } from "fzf"
import { ModeConfig } from "../../../src/shared/modes"
import * as path from "path"
export interface SearchResult {
path: string
type: "file" | "folder"
label?: string
}
export function insertMention(
text: string,
position: number,
@ -80,6 +86,7 @@ export function getContextMenuOptions(
query: string,
selectedType: ContextMenuOptionType | null = null,
queryItems: ContextMenuQueryItem[],
dynamicSearchResults: SearchResult[] = [],
modes?: ModeConfig[],
): ContextMenuQueryItem[] {
// Handle slash commands for modes
@ -203,7 +210,34 @@ export function getContextMenuOptions(
}
}
// Create searchable strings array for fzf
if (dynamicSearchResults.length > 0) {
// Convert search results to queryItems format
const searchResultItems = dynamicSearchResults.map((result) => {
const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
return {
type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: formattedPath,
label: result.label || path.basename(result.path),
description: formattedPath,
}
})
const allItems = [...suggestions, ...searchResultItems]
// Remove duplicates
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
}
// Fallback to original static filtering if no dynamic results
const searchableItems = queryItems.map((item) => ({
original: item,
searchStr: [item.value, item.label, item.description].filter(Boolean).join(" "),
@ -257,26 +291,25 @@ export function shouldShowContextMenu(text: string, position: number): boolean {
if (text.startsWith("/")) {
return position <= text.length && !text.includes(" ")
}
const beforeCursor = text.slice(0, position)
const atIndex = beforeCursor.lastIndexOf("@")
if (atIndex === -1) return false
if (atIndex === -1) {
return false
}
const textAfterAt = beforeCursor.slice(atIndex + 1)
// Check if there's any whitespace after the '@'
if (/\s/.test(textAfterAt)) return false
// Don't show the menu if it's a URL
if (textAfterAt.toLowerCase().startsWith("http")) return false
// Don't show the menu if it's a problems or terminal
if (textAfterAt.toLowerCase().startsWith("problems") || textAfterAt.toLowerCase().startsWith("terminal"))
// Don't show the menu if it's clearly a URL
if (textAfterAt.toLowerCase().startsWith("http")) {
return false
}
// NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks
// If there's a space after @, don't show the menu (normal @ mention)
if (textAfterAt.indexOf(" ") === 0) {
return false
}
// Show the menu if there's just '@' or '@' followed by some text (but not a URL)
// Show menu in all other cases
return true
}