Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2902,11 +2902,7 @@ ${comment.suggestion}
}
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
)
const results = await searchWorkspaceFiles(message.query || "", workspacePath, 100)

// Send results back to webview
await provider.postMessageToWebview({
Expand Down
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "matterai",
"version": "5.6.3",
"version": "5.6.4",
"icon": "assets/icons/matterai-ic.png",
"galleryBanner": {
"color": "#FFFFFF",
Expand Down
28 changes: 28 additions & 0 deletions src/services/search/__tests__/file-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest"

import { rankWorkspaceSearchResults, type FileResult } from "../file-search"

describe("rankWorkspaceSearchResults", () => {
it("prefers exact basename and extension matches over similarly named siblings", () => {
const results: FileResult[] = [
{ path: "src/components/ChatTextArea.ts", type: "file", label: "ChatTextArea.ts" },
{ path: "src/components/ChatTextArea.tsx", type: "file", label: "ChatTextArea.tsx" },
{ path: "src/components/ChatTextArea.test.tsx", type: "file", label: "ChatTextArea.test.tsx" },
]

const ranked = rankWorkspaceSearchResults(results, "ChatTextArea.tsx")

expect(ranked[0]?.path).toBe("src/components/ChatTextArea.tsx")
})

it("prefers the file when the query includes the file extension", () => {
const results: FileResult[] = [
{ path: "src/components/button", type: "folder", label: "button" },
{ path: "src/components/button.tsx", type: "file", label: "button.tsx" },
]

const ranked = rankWorkspaceSearchResults(results, "button.tsx")

expect(ranked[0]?.path).toBe("src/components/button.tsx")
})
})
80 changes: 73 additions & 7 deletions src/services/search/file-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,69 @@ import { getBinPath } from "../ripgrep"

export type FileResult = { path: string; type: "file" | "folder"; label?: string }

const DEFAULT_FILE_SCAN_LIMIT = Number.MAX_SAFE_INTEGER
const MIN_FZF_CANDIDATES = 200

function normalizeForSearch(value: string): string {
return value.toLowerCase()
}

function getItemBasename(item: FileResult): string {
return path.posix.basename(item.path)
}

function getSearchString(item: FileResult): string {
const basename = getItemBasename(item)
return `${basename} ${item.path}`
}

function hasFileExtension(value: string): boolean {
return /\.[a-z0-9]+$/i.test(value)
}

function getRankSignals(item: FileResult, normalizedQuery: string) {
const basename = normalizeForSearch(getItemBasename(item))
const fullPath = normalizeForSearch(item.path)
const queryHasExtension = hasFileExtension(normalizedQuery)

return {
exactBasename: basename === normalizedQuery ? 1 : 0,
exactPathSuffix: fullPath.endsWith(normalizedQuery) ? 1 : 0,
basenameStartsWith: basename.startsWith(normalizedQuery) ? 1 : 0,
pathSegmentMatch: fullPath.includes(`/${normalizedQuery}`) ? 1 : 0,
basenameIncludes: basename.includes(normalizedQuery) ? 1 : 0,
extensionExactness: queryHasExtension && basename.endsWith(normalizedQuery) ? 1 : 0,
isFile: item.type === "file" ? 1 : 0,
basenameLength: basename.length,
pathLength: fullPath.length,
}
}

export function rankWorkspaceSearchResults(results: FileResult[], query: string): FileResult[] {
const normalizedQuery = normalizeForSearch(query.trim())
if (!normalizedQuery) {
return results
}

return [...results].sort((left, right) => {
const a = getRankSignals(left, normalizedQuery)
const b = getRankSignals(right, normalizedQuery)

return (
b.exactBasename - a.exactBasename ||
b.extensionExactness - a.extensionExactness ||
b.exactPathSuffix - a.exactPathSuffix ||
b.basenameStartsWith - a.basenameStartsWith ||
b.pathSegmentMatch - a.pathSegmentMatch ||
b.basenameIncludes - a.basenameIncludes ||
b.isFile - a.isFile ||
a.basenameLength - b.basenameLength ||
a.pathLength - b.pathLength ||
left.path.localeCompare(right.path)
)
})
Comment on lines +55 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Performance

Issue: getRankSignals performs multiple string operations and is called inside the sort comparator. For an array of N items, the comparator runs O(N log N) times, leading to redundant calculations and potential performance bottlenecks during search.

Fix: Use the Schwartzian transform pattern to pre-calculate the rank signals once per item before sorting.

Impact: Significantly improves search ranking performance by reducing redundant string operations.

Suggested change
return [...results].sort((left, right) => {
const a = getRankSignals(left, normalizedQuery)
const b = getRankSignals(right, normalizedQuery)
return (
b.exactBasename - a.exactBasename ||
b.extensionExactness - a.extensionExactness ||
b.exactPathSuffix - a.exactPathSuffix ||
b.basenameStartsWith - a.basenameStartsWith ||
b.pathSegmentMatch - a.pathSegmentMatch ||
b.basenameIncludes - a.basenameIncludes ||
b.isFile - a.isFile ||
a.basenameLength - b.basenameLength ||
a.pathLength - b.pathLength ||
left.path.localeCompare(right.path)
)
})
const withSignals = results.map((item) => ({ item, signals: getRankSignals(item, normalizedQuery) }))
return withSignals
.sort((left, right) => {
const a = left.signals
const b = right.signals
return (
b.exactBasename - a.exactBasename ||
b.extensionExactness - a.extensionExactness ||
b.exactPathSuffix - a.exactPathSuffix ||
b.basenameStartsWith - a.basenameStartsWith ||
b.pathSegmentMatch - a.pathSegmentMatch ||
b.basenameIncludes - a.basenameIncludes ||
b.isFile - a.isFile ||
a.basenameLength - b.basenameLength ||
a.pathLength - b.pathLength ||
left.item.path.localeCompare(right.item.path)
)
})
.map((obj) => obj.item)

}

export async function executeRipgrep({
args,
workspacePath,
Expand Down Expand Up @@ -87,7 +150,7 @@ export async function executeRipgrep({

export async function executeRipgrepForFiles(
workspacePath: string,
limit: number = 5000,
limit: number = DEFAULT_FILE_SCAN_LIMIT,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
const args = [
"--files",
Expand All @@ -114,7 +177,7 @@ export async function searchWorkspaceFiles(
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
try {
// Get all files and directories (from our modified function)
const allItems = await executeRipgrepForFiles(workspacePath, 5000)
const allItems = await executeRipgrepForFiles(workspacePath)

// If no query, just return the top items
if (!query.trim()) {
Expand All @@ -124,22 +187,25 @@ export async function searchWorkspaceFiles(
// Create search items for all files AND directories
const searchItems = allItems.map((item) => ({
original: item,
searchStr: `${item.path} ${item.label || ""}`,
searchStr: getSearchString(item),
}))

// Run fzf search on all items
const fzf = new Fzf(searchItems, {
selector: (item) => item.searchStr,
tiebreakers: [byLengthAsc],
limit: limit,
limit: Math.max(limit * 10, MIN_FZF_CANDIDATES),
})

// Get all matching results from fzf
const fzfResults = fzf.find(query).map((result) => result.item.original)
// Get a broad slice of matching results from fzf, then apply ranking tuned for file names/extensions.
const rankedMatches = rankWorkspaceSearchResults(
fzf.find(query).map((result) => result.item.original),
query,
).slice(0, limit)

// Verify types of the shortest results
const verifiedResults = await Promise.all(
fzfResults.map(async (result) => {
rankedMatches.map(async (result) => {
const fullPath = path.join(workspacePath, result.path)
// Verify if the path exists and is actually a directory
if (fs.existsSync(fullPath)) {
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export type UserOrganizationWithApiKey = {
}

export type ProfileData = {
email: string
kilocodeToken: string
user: {
id: string
Expand Down
187 changes: 91 additions & 96 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,99 +314,6 @@ export const ChatTextArea = forwardRef<HTMLDivElement, ChatTextAreaProps>(
}
}, [showContextMenu, setShowContextMenu])

const handleMentionSelect = useCallback(
(type: ContextMenuOptionType, value?: string) => {
// forked_change start
if (type === ContextMenuOptionType.Image) {
setShowContextMenu(false)
setSelectedType(null)

const beforeCursor = inputValue.slice(0, cursorPosition)
const afterCursor = inputValue.slice(cursorPosition)
const lastAtIndex = beforeCursor.lastIndexOf("@")

if (lastAtIndex !== -1) {
const newValue = beforeCursor.slice(0, lastAtIndex) + afterCursor
setInputValue(newValue)
intendedCursorPositionRef.current = lastAtIndex
}

onSelectImages()
return
}
// forked_change end

if (type === ContextMenuOptionType.NoResults) {
return
}

// if (type === ContextMenuOptionType.Mode && value) {
// // Handle mode selection.
// setMode(value)
// setInputValue("")
// setShowContextMenu(false)
// vscode.postMessage({ type: "mode", text: value })
// return
// }

if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder
// type === ContextMenuOptionType.Git
) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
setSelectedMenuIndex(0)
return
}
}

setShowContextMenu(false)
setSelectedType(null)

let insertValue = value || ""

// if (type === ContextMenuOptionType.URL) {
// insertValue = value || ""
// } else
if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder
// type === ContextMenuOptionType.OpenedFile
) {
const fullPath = value || ""
if (fullPath.startsWith("/")) {
const segments = fullPath.split("/").filter(Boolean)
const filename = segments.pop() || fullPath
insertValue = filename
mentionMapRef.current.set(filename, fullPath)
} else {
insertValue = fullPath
}
}
// else if (type === ContextMenuOptionType.Problems) {
// insertValue = "problems"
// } else if (type === ContextMenuOptionType.Terminal) {
// insertValue = "terminal"
// } else if (type === ContextMenuOptionType.Git) {
// insertValue = value || ""
// }

const { newValue, mentionIndex } = insertMention(inputValue, cursorPosition, insertValue)

setInputValue(newValue)
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
setCursorPosition(newCursorPosition)
intendedCursorPositionRef.current = newCursorPosition

setTimeout(() => {
textAreaRef.current?.focus()
}, 0)
},
[setInputValue, cursorPosition, inputValue, onSelectImages],
)

// forked_change start: pull slash commands from Cline
const handleSlashCommandsSelect = useCallback(
(command: SlashCommand) => {
Expand Down Expand Up @@ -557,6 +464,85 @@ export const ChatTextArea = forwardRef<HTMLDivElement, ChatTextAreaProps>(
return computeOffset(textAreaRef.current, anchorNode, anchorOffset)
}, [getNodeTextLength])

const getCurrentInputSnapshot = useCallback(() => {
return {
value: getPlainTextFromInput(),
cursor: getCaretPosition(),
}
}, [getCaretPosition, getPlainTextFromInput])

const handleMentionSelect = useCallback(
(type: ContextMenuOptionType, value?: string) => {
// forked_change start
if (type === ContextMenuOptionType.Image) {
setShowContextMenu(false)
setSelectedType(null)

const { value: currentValue, cursor: currentCursorPosition } = getCurrentInputSnapshot()
const beforeCursor = currentValue.slice(0, currentCursorPosition)
const afterCursor = currentValue.slice(currentCursorPosition)
const lastAtIndex = beforeCursor.lastIndexOf("@")

if (lastAtIndex !== -1) {
const newValue = beforeCursor.slice(0, lastAtIndex) + afterCursor
setInputValue(newValue)
setCursorPosition(lastAtIndex)
intendedCursorPositionRef.current = lastAtIndex
} else if (currentValue !== inputValue) {
setInputValue(currentValue)
setCursorPosition(currentCursorPosition)
intendedCursorPositionRef.current = currentCursorPosition
}

onSelectImages()
return
}
// forked_change end

if (type === ContextMenuOptionType.NoResults) {
return
}

if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
setSelectedMenuIndex(0)
return
}
}

setShowContextMenu(false)
setSelectedType(null)

let insertValue = value || ""

if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
const fullPath = value || ""
if (fullPath.startsWith("/")) {
const segments = fullPath.split("/").filter(Boolean)
const filename = segments.pop() || fullPath
insertValue = filename
mentionMapRef.current.set(filename, fullPath)
} else {
insertValue = fullPath
}
}

const { newValue, mentionIndex } = insertMention(inputValue, cursorPosition, insertValue)

setInputValue(newValue)
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
setCursorPosition(newCursorPosition)
intendedCursorPositionRef.current = newCursorPosition

setTimeout(() => {
textAreaRef.current?.focus()
}, 0)
},
[cursorPosition, getCurrentInputSnapshot, inputValue, onSelectImages, setInputValue, setCursorPosition],
)

const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items
Expand Down Expand Up @@ -1625,8 +1611,17 @@ export const ChatTextArea = forwardRef<HTMLDivElement, ChatTextAreaProps>(
if (showContextMenu || !textAreaRef.current) return

textAreaRef.current.focus()

setInputValue(`${inputValue} @`)
const { value: currentValue, cursor: currentCursorPosition } =
getCurrentInputSnapshot()
const nextValue =
currentValue.slice(0, currentCursorPosition) +
" @" +
currentValue.slice(currentCursorPosition)
const nextCursorPosition = currentCursorPosition + 2

setInputValue(nextValue)
setCursorPosition(nextCursorPosition)
intendedCursorPositionRef.current = nextCursorPosition
setShowContextMenu(true)
setSearchQuery("")
setSelectedMenuIndex(4)
Expand Down Expand Up @@ -1822,7 +1817,7 @@ export const ChatTextArea = forwardRef<HTMLDivElement, ChatTextAreaProps>(
style={{
left: "16px",
zIndex: 2,
marginTop: "14px", // kilocode_change
marginTop: "4px",
marginBottom: 0,
}}
/>
Expand Down
Loading
Loading