diff --git a/package-lock.json b/package-lock.json index 805b1a93e4..96527c9546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3a8bfd1e2b..8ad1cffc33 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6f95eb2b2f..c28eab40fd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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 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 { diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index 639317d6f4..15df5f0c50 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -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 { +/** + * Get the path to the ripgrep binary within the VSCode installation + */ +export async function getBinPath(vscodeAppRoot: string): Promise { 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 { ) } -async function pathExists(path: string): Promise { - return new Promise((resolve) => { - fs.access(path, (err) => { - resolve(err === null) - }) - }) -} - async function execRipgrep(bin: string, args: string[]): Promise { return new Promise((resolve, reject) => { const rgProcess = childProcess.spawn(bin, args) diff --git a/src/services/search/file-search.ts b/src/services/search/file-search.ts new file mode 100644 index 0000000000..ef8d0bd959 --- /dev/null +++ b/src/services/search/file-search.ts @@ -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 [] + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index f40eadb84c..4f03f0fcd0 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -56,6 +56,7 @@ export interface ExtensionMessage { | "remoteBrowserEnabled" | "ttsStart" | "ttsStop" + | "fileSearchResults" text?: string action?: | "chatButtonClicked" @@ -92,6 +93,12 @@ export interface ExtensionMessage { values?: Record requestId?: string promptText?: string + results?: Array<{ + path: string + type: "file" | "folder" + label?: string + }> + error?: string } export interface ApiConfigMeta { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 67272adc0f..8265b8a9e5 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -114,6 +114,7 @@ export interface WebviewMessage { | "browserConnectionResult" | "remoteBrowserEnabled" | "language" + | "searchFiles" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 137d16cb00..b0b6362fce 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState() const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) + const [fileSearchResults, setFileSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchRequestId, setSearchRequestId] = useState("") // Close dropdown when clicking outside. useEffect(() => { @@ -76,7 +80,7 @@ const ChatTextArea = forwardRef( 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( })) 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(undefined) @@ -275,6 +284,7 @@ const ChatTextArea = forwardRef( searchQuery, selectedType, queryItems, + fileSearchResults, getAllModes(customModes), ) const optionsLength = options.length @@ -310,6 +320,7 @@ const ChatTextArea = forwardRef( searchQuery, selectedType, queryItems, + fileSearchResults, getAllModes(customModes), )[selectedMenuIndex] if ( @@ -378,6 +389,7 @@ const ChatTextArea = forwardRef( justDeletedSpaceAfterMention, queryItems, customModes, + fileSearchResults, ], ) @@ -387,6 +399,8 @@ const ChatTextArea = forwardRef( setIntendedCursorPosition(null) // Reset the state. } }, [inputValue, intendedCursorPosition]) + // Ref to store the search timeout + const searchTimeoutRef = useRef(null) const handleInputChange = useCallback( (e: React.ChangeEvent) => { @@ -408,8 +422,32 @@ const ChatTextArea = forwardRef( 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( } else { setSearchQuery("") setSelectedMenuIndex(-1) + setFileSearchResults([]) // Clear file search results } }, - [setInputValue], + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], ) useEffect(() => { @@ -675,6 +714,8 @@ const ChatTextArea = forwardRef( selectedType={selectedType} queryItems={queryItems} modes={getAllModes(customModes)} + loading={searchLoading} + dynamicSearchResults={fileSearchResults} /> )} diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 20bd5222f6..5d2df631db 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -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 = ({ @@ -23,13 +30,14 @@ const ContextMenu: React.FC = ({ selectedType, queryItems, modes, + loading = false, + dynamicSearchResults = [], }) => { const menuRef = useRef(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 = ({ maxHeight: "200px", overflowY: "auto", }}> - {filteredOptions.map((option, index) => ( -
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) => (
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)}> +
+ {option.type !== ContextMenuOptionType.Mode && getIconForOption(option) && ( + + )} + {renderOptionContent(option)} +
+ {(option.type === ContextMenuOptionType.File || + option.type === ContextMenuOptionType.Folder || + option.type === ContextMenuOptionType.Git) && + !option.value && ( + + )} + {(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)) && ( - )} - {renderOptionContent(option)} -
- {(option.type === ContextMenuOptionType.File || - option.type === ContextMenuOptionType.Folder || - option.type === ContextMenuOptionType.Git) && - !option.value && ( - )} - {(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)) && ( - - )} +
+ )) + ) : ( +
+ No results found
- ))} + )} ) diff --git a/webview-ui/src/utils/__tests__/context-mentions.test.ts b/webview-ui/src/utils/__tests__/context-mentions.test.ts index ee40a9f724..bd3696f191 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.test.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.test.ts @@ -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) }) }) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 600a2f760e..8408e1a11d 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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 }