mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Move context-mention file/folder search to the server (#1824)
This commit is contained in:
parent
499b8e4665
commit
f181da1454
11 changed files with 362 additions and 90 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
125
src/services/search/file-search.ts
Normal file
125
src/services/search/file-search.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export interface WebviewMessage {
|
|||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
| "language"
|
||||
| "searchFiles"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue