mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
fix(vscode): improve file mention search ranking (#10053)
* fix(vscode): improve file mention search ranking * fix(vscode): avoid stale file mention selections
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Improve @ file search relevance and keep mention suggestions responsive while typing.
|
||||
@@ -248,6 +248,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"friendly-words": "1.3.1",
|
||||
"fuzzysort": "3.1.0",
|
||||
"ignore": "^7.0.3",
|
||||
"js-tiktoken": "^1.0.18",
|
||||
"lru-cache": "^11.0.2",
|
||||
|
||||
@@ -927,6 +927,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"fuzzysort": "3.1.0",
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
|
||||
@@ -607,30 +607,3 @@ export function isEventFromForeignProject(event: Event, expectedProjectID: strin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge open-tab paths with backend file search results for the @ mention dropdown.
|
||||
*
|
||||
* Ordering: active file → other open tabs → backend results (all deduplicated).
|
||||
* When a query is present, open tabs are filtered to only include matches.
|
||||
* The `active` path (if provided) is placed first when it exists in `open`.
|
||||
*/
|
||||
export function mergeFileSearchResults(input: {
|
||||
query: string
|
||||
backend: string[]
|
||||
open: Set<string>
|
||||
active?: string
|
||||
}): string[] {
|
||||
const norm = (p: string) => p.replaceAll("\\", "/")
|
||||
const query = norm(input.query).trim().toLowerCase()
|
||||
const open = new Set([...input.open].map(norm))
|
||||
const active = input.active ? norm(input.active) : undefined
|
||||
const backend = input.backend.map(norm)
|
||||
const ok = (p: string) => !query || p.toLowerCase().includes(query)
|
||||
const tabs =
|
||||
active && open.has(active) && ok(active)
|
||||
? [active, ...[...open].filter((p) => p !== active && ok(p))]
|
||||
: [...open].filter(ok)
|
||||
const seen = new Set(tabs)
|
||||
return [...tabs, ...backend.filter((p) => !seen.has(p))]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FileSearchItem = { path: string; type: "file" | "folder" }
|
||||
export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" }
|
||||
|
||||
const normalize = (p: string) => p.replaceAll("\\", "/")
|
||||
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
|
||||
@@ -18,9 +18,20 @@ function rank(query: string, p: string): number {
|
||||
return 4
|
||||
}
|
||||
|
||||
export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] {
|
||||
export function mergeFileSearchItems(input: {
|
||||
query: string
|
||||
files: string[]
|
||||
folders: string[]
|
||||
open?: Set<string>
|
||||
}): FileSearchItem[] {
|
||||
const query = normalize(input.query).trim().toLowerCase()
|
||||
const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const }))
|
||||
const open = new Set([...(input.open ?? [])].map(normalize))
|
||||
const files = input.files.map((p) => {
|
||||
const path = normalize(p)
|
||||
return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) }
|
||||
})
|
||||
const pinned = files.filter((item) => item.type === "opened-file")
|
||||
const rest = files.filter((item) => !open.has(item.path))
|
||||
// Dedup folders against themselves; a file and a folder that share a stem are distinct entries.
|
||||
const seen = new Set<string>()
|
||||
const folders = input.folders
|
||||
@@ -40,6 +51,6 @@ export function mergeFileSearchItems(input: { query: string; files: string[]; fo
|
||||
|
||||
const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item)
|
||||
const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item)
|
||||
return [...boosted, ...files, ...rest]
|
||||
const other = sorted.filter((x) => x.rank > 1).map((x) => x.item)
|
||||
return [...pinned, ...boosted, ...rest, ...other]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
function base(p: string): string {
|
||||
const clean = p.replace(/\/+$/, "")
|
||||
return clean.split("/").pop() ?? clean
|
||||
}
|
||||
|
||||
function depth(p: string): number {
|
||||
return p.split("/").length - 1
|
||||
}
|
||||
|
||||
function score(query: string, p: string) {
|
||||
const name = base(p)
|
||||
const label = fuzzysort.single(query, name)
|
||||
const path = fuzzysort.single(query, p)
|
||||
return {
|
||||
p,
|
||||
label,
|
||||
path,
|
||||
depth: depth(p),
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: ReturnType<typeof score>, b: ReturnType<typeof score>): number {
|
||||
const alabel = a.label !== null
|
||||
const blabel = b.label !== null
|
||||
if (alabel !== blabel) return alabel ? -1 : 1
|
||||
|
||||
const ascore = a.label?.score ?? a.path?.score ?? 0
|
||||
const bscore = b.label?.score ?? b.path?.score ?? 0
|
||||
if (ascore !== bscore) return bscore - ascore
|
||||
|
||||
const aname = base(a.p)
|
||||
const bname = base(b.p)
|
||||
if (aname.length !== bname.length) return aname.length - bname.length
|
||||
if (a.depth !== b.depth) return a.depth - b.depth
|
||||
if (a.p.length !== b.p.length) return a.p.length - b.p.length
|
||||
return a.p.localeCompare(b.p)
|
||||
}
|
||||
|
||||
function rankOpen(query: string, paths: string[]): string[] {
|
||||
if (!query || !paths.length) return paths
|
||||
const scored: Array<ReturnType<typeof score>> = []
|
||||
for (const p of paths) {
|
||||
const result = score(query, p)
|
||||
if (result.path) scored.push(result)
|
||||
}
|
||||
return scored.sort(compare).map((x) => x.p)
|
||||
}
|
||||
|
||||
function rankBackend(query: string, paths: string[]): string[] {
|
||||
if (!query || paths.length <= 1) return paths
|
||||
return paths
|
||||
.map((p) => score(query, p))
|
||||
.sort(compare)
|
||||
.map((x) => x.p)
|
||||
}
|
||||
|
||||
export function mergeFileSearchResults(input: {
|
||||
query: string
|
||||
backend: string[]
|
||||
open: Set<string>
|
||||
active?: string
|
||||
}): string[] {
|
||||
const norm = (p: string) => p.replaceAll("\\", "/")
|
||||
const query = norm(input.query).trim().toLowerCase()
|
||||
const open = new Set([...input.open].map(norm))
|
||||
const active = input.active ? norm(input.active) : undefined
|
||||
const backend = input.backend.map(norm)
|
||||
const matched = rankOpen(query, [...open])
|
||||
const tabs = (() => {
|
||||
if (!active || !matched.includes(active)) return matched
|
||||
return [active, ...matched.filter((p) => p !== active)]
|
||||
})()
|
||||
const seen = new Set(tabs)
|
||||
const remaining = backend.filter((p) => !seen.has(p))
|
||||
return [...tabs, ...rankBackend(query, remaining)]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { mergeFileSearchResults } from "../kilo-provider-utils"
|
||||
import { mergeFileSearchResults } from "./file-search-results"
|
||||
import { mergeFileSearchItems } from "./file-search-items"
|
||||
|
||||
type Message = {
|
||||
@@ -42,7 +42,12 @@ export async function handleFileSearch(input: Input): Promise<void> {
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({ query, files: result, folders })
|
||||
const items = mergeFileSearchItems({
|
||||
query,
|
||||
files: result,
|
||||
folders,
|
||||
open: new Set(active ? [active, ...open] : open),
|
||||
})
|
||||
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildTextAfterMentionSelect,
|
||||
buildFileAttachments,
|
||||
buildMentionResults,
|
||||
filterMentionResults,
|
||||
} from "../../webview-ui/src/hooks/file-mention-utils"
|
||||
|
||||
describe("AT_PATTERN", () => {
|
||||
@@ -71,6 +72,21 @@ describe("buildMentionResults", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
|
||||
expect(result).toEqual([{ type: "folder", value: "src" }])
|
||||
})
|
||||
|
||||
it("preserves opened file result type", () => {
|
||||
const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }])
|
||||
expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterMentionResults", () => {
|
||||
it("keeps matching file results for the latest query", () => {
|
||||
const result = filterMentionResults("gi", [
|
||||
{ type: "file", value: "README.md" },
|
||||
{ type: "file", value: "src/git.ts" },
|
||||
])
|
||||
expect(result).toEqual([{ type: "file", value: "src/git.ts" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
|
||||
|
||||
describe("mergeFileSearchItems", () => {
|
||||
it("puts exact folder matches before file matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "script",
|
||||
files: ["script/hooks", "script/release", "script/beta.ts"],
|
||||
folders: ["script/", "script/run-script/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "script/", type: "folder" },
|
||||
{ path: "script/hooks", type: "file" },
|
||||
{ path: "script/release", type: "file" },
|
||||
{ path: "script/beta.ts", type: "file" },
|
||||
{ path: "script/run-script/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps file ordering before non-prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts"],
|
||||
folders: ["src/latest/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
{ path: "src/latest/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes Windows separators for matching and output", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "kilo-vscode",
|
||||
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
|
||||
folders: ["packages\\kilo-vscode\\"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "packages/kilo-vscode/", type: "folder" },
|
||||
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps active and open file results before prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "e",
|
||||
files: ["sdks/vscode/src/extension.ts"],
|
||||
folders: ["packages/extensions/", "packages/example/", "packages/core/src/effect/"],
|
||||
open: new Set(["sdks/vscode/src/extension.ts"]),
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "sdks/vscode/src/extension.ts", type: "opened-file" },
|
||||
{ path: "packages/extensions/", type: "folder" },
|
||||
{ path: "packages/example/", type: "folder" },
|
||||
{ path: "packages/core/src/effect/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps opened files as a distinct priority group before non-open files", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts", "src/test-helper.ts"],
|
||||
folders: ["test/"],
|
||||
open: new Set(["src/test-helper.ts"]),
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test-helper.ts", type: "opened-file" },
|
||||
{ path: "test/", type: "folder" },
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { mergeFileSearchResults } from "../../src/kilo-provider/file-search-results"
|
||||
|
||||
describe("mergeFileSearchResults", () => {
|
||||
it("returns backend results when no open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts"],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("places open files before backend results", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts", "src/c.ts"],
|
||||
open: new Set(["src/c.ts", "src/d.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("places active file first among open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts"],
|
||||
open: new Set(["src/b.ts", "src/c.ts"]),
|
||||
active: "src/c.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"])
|
||||
})
|
||||
|
||||
it("ignores active file when it is not in open set", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts"],
|
||||
open: new Set(["src/b.ts"]),
|
||||
active: "src/x.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/b.ts", "src/a.ts"])
|
||||
})
|
||||
|
||||
it("deduplicates open files from backend results", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts"],
|
||||
open: new Set(["src/a.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("filters open files by query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "config",
|
||||
backend: ["src/config.ts", "src/util.ts"],
|
||||
open: new Set(["src/index.ts", "src/config.ts", "README.md"]),
|
||||
})
|
||||
expect(result).toEqual(["src/config.ts", "src/util.ts"])
|
||||
})
|
||||
|
||||
it("query filtering is case-insensitive", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "READ",
|
||||
backend: [],
|
||||
open: new Set(["README.md", "src/index.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["README.md"])
|
||||
})
|
||||
|
||||
it("shows all open files on empty query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: [],
|
||||
open: new Set(["src/a.ts", "src/b.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("shows all open files on whitespace-only query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: " ",
|
||||
backend: ["src/x.ts"],
|
||||
open: new Set(["src/a.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/x.ts"])
|
||||
})
|
||||
|
||||
it("handles forward-slash paths (Windows-normalized)", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/utils/path.ts"],
|
||||
open: new Set(["src/utils/path.ts", "src/index.ts"]),
|
||||
active: "src/utils/path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("normalizes backslash paths before filtering and deduping", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "utils/path",
|
||||
backend: ["src\\utils\\path.ts"],
|
||||
open: new Set(["src/utils/path.ts"]),
|
||||
active: "src\\utils\\path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts"])
|
||||
})
|
||||
|
||||
it("includes open tabs that fuzzy-match but are not substring matches", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "authn",
|
||||
backend: [],
|
||||
open: new Set(["authentication.ts", "unrelated.ts"]),
|
||||
})
|
||||
expect(result).toContain("authentication.ts")
|
||||
expect(result).not.toContain("unrelated.ts")
|
||||
})
|
||||
|
||||
it("ranks open tabs by fuzzy match quality, not insertion order", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "auth",
|
||||
backend: [],
|
||||
open: new Set(["long-authentication-module.ts", "auth.ts"]),
|
||||
})
|
||||
expect(result[0]).toBe("auth.ts")
|
||||
})
|
||||
|
||||
it("ranks open tabs by filename before directory matches", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "provider",
|
||||
backend: [],
|
||||
open: new Set(["src/provider/auth.ts", "src/provider.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/provider.ts", "src/provider/auth.ts"])
|
||||
})
|
||||
|
||||
it("boosts backend results where query matches the basename over full-path matches", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "auth",
|
||||
backend: ["src/authentication-module.ts", "packages/a/b/c/d/auth.ts"],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result.indexOf("packages/a/b/c/d/auth.ts")).toBeLessThan(result.indexOf("src/authentication-module.ts"))
|
||||
})
|
||||
|
||||
it("uses path depth as tiebreaker when basename scores are equal", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "auth",
|
||||
backend: ["a/b/c/d/e/auth-service.ts", "src/auth-service.ts"],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result.indexOf("src/auth-service.ts")).toBeLessThan(result.indexOf("a/b/c/d/e/auth-service.ts"))
|
||||
})
|
||||
|
||||
it("uses filename length as tiebreaker before path depth", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "auth",
|
||||
backend: ["src/authentication-module.ts", "a/b/c/auth.ts"],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result[0]).toBe("a/b/c/auth.ts")
|
||||
})
|
||||
|
||||
it("preserves camel-case scoring for acronym-style queries", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "amprov",
|
||||
backend: [
|
||||
"packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md",
|
||||
"packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts",
|
||||
],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result[0]).toBe("packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts")
|
||||
})
|
||||
})
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
isEventFromForeignProject,
|
||||
mapCloudSessionMessageToWebviewMessage,
|
||||
MessageConfirmation,
|
||||
mergeFileSearchResults,
|
||||
getErrorMessage,
|
||||
getConfigErrorDetails,
|
||||
type ProviderInfo,
|
||||
} from "../../src/kilo-provider-utils"
|
||||
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
|
||||
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
|
||||
import type {
|
||||
Session,
|
||||
@@ -607,152 +605,6 @@ describe("mapCloudSessionMessage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchItems", () => {
|
||||
it("puts exact folder matches before file matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "script",
|
||||
files: ["script/hooks", "script/release", "script/beta.ts"],
|
||||
folders: ["script/", "script/run-script/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "script/", type: "folder" },
|
||||
{ path: "script/hooks", type: "file" },
|
||||
{ path: "script/release", type: "file" },
|
||||
{ path: "script/beta.ts", type: "file" },
|
||||
{ path: "script/run-script/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps file ordering before non-prefix folder matches", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "test",
|
||||
files: ["src/test.ts"],
|
||||
folders: ["src/latest/"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "src/test.ts", type: "file" },
|
||||
{ path: "src/latest/", type: "folder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes Windows separators for matching and output", () => {
|
||||
const result = mergeFileSearchItems({
|
||||
query: "kilo-vscode",
|
||||
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
|
||||
folders: ["packages\\kilo-vscode\\"],
|
||||
})
|
||||
expect(result).toEqual([
|
||||
{ path: "packages/kilo-vscode/", type: "folder" },
|
||||
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeFileSearchResults", () => {
|
||||
it("returns backend results when no open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts"],
|
||||
open: new Set(),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("places open files before backend results", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts", "src/c.ts"],
|
||||
open: new Set(["src/c.ts", "src/d.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("places active file first among open files", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts"],
|
||||
open: new Set(["src/b.ts", "src/c.ts"]),
|
||||
active: "src/c.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"])
|
||||
})
|
||||
|
||||
it("ignores active file when it is not in open set", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts"],
|
||||
open: new Set(["src/b.ts"]),
|
||||
active: "src/x.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/b.ts", "src/a.ts"])
|
||||
})
|
||||
|
||||
it("deduplicates open files from backend results", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/a.ts", "src/b.ts"],
|
||||
open: new Set(["src/a.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("filters open files by query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "config",
|
||||
backend: ["src/config.ts", "src/util.ts"],
|
||||
open: new Set(["src/index.ts", "src/config.ts", "README.md"]),
|
||||
})
|
||||
expect(result).toEqual(["src/config.ts", "src/util.ts"])
|
||||
})
|
||||
|
||||
it("query filtering is case-insensitive", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "READ",
|
||||
backend: [],
|
||||
open: new Set(["README.md", "src/index.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["README.md"])
|
||||
})
|
||||
|
||||
it("shows all open files on empty query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: [],
|
||||
open: new Set(["src/a.ts", "src/b.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/b.ts"])
|
||||
})
|
||||
|
||||
it("shows all open files on whitespace-only query", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: " ",
|
||||
backend: ["src/x.ts"],
|
||||
open: new Set(["src/a.ts"]),
|
||||
})
|
||||
expect(result).toEqual(["src/a.ts", "src/x.ts"])
|
||||
})
|
||||
|
||||
it("handles forward-slash paths (Windows-normalized)", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "",
|
||||
backend: ["src/utils/path.ts"],
|
||||
open: new Set(["src/utils/path.ts", "src/index.ts"]),
|
||||
active: "src/utils/path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("normalizes backslash paths before filtering and deduping", () => {
|
||||
const result = mergeFileSearchResults({
|
||||
query: "utils/path",
|
||||
backend: ["src\\utils\\path.ts"],
|
||||
open: new Set(["src/utils/path.ts"]),
|
||||
active: "src\\utils\\path.ts",
|
||||
})
|
||||
expect(result).toEqual(["src/utils/path.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("extracts message from an Error instance", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom")
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("useFileMention", () => {
|
||||
it("keeps previous file results visible while the next search is pending", async () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
mention.onInput("@e", 2)
|
||||
await wait(170)
|
||||
|
||||
const first = posted.at(-1)
|
||||
expect(first?.type).toBe("requestFileSearch")
|
||||
expect(first).toMatchObject({ query: "e", requestId: "file-search-1" })
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: "file-search-1",
|
||||
dir: "/repo",
|
||||
paths: ["sdks/vscode/src/extension.ts"],
|
||||
items: [{ path: "sdks/vscode/src/extension.ts", type: "opened-file" }],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }])
|
||||
|
||||
mention.onInput("@ex", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("does not keep stale file results visible for unrelated queries", async () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
mention.onInput("@read", 5)
|
||||
await wait(170)
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: "file-search-1",
|
||||
dir: "/repo",
|
||||
paths: ["README.md"],
|
||||
items: [{ path: "README.md", type: "file" }],
|
||||
})
|
||||
}
|
||||
|
||||
mention.onInput("@zz", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("filters visible results synchronously while a new search is pending", async () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
mention.onInput("@g", 2)
|
||||
await wait(170)
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: "file-search-1",
|
||||
dir: "/repo",
|
||||
paths: ["README.md", "src/git.ts"],
|
||||
items: [
|
||||
{ path: "README.md", type: "file" },
|
||||
{ path: "src/git.ts", type: "file" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
mention.onInput("@gi", 3)
|
||||
|
||||
expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ export type MentionResult =
|
||||
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
|
||||
| { type: "git-changes"; value: typeof GIT_CHANGES_MENTION; label: string; description: string }
|
||||
| { type: "file"; value: string }
|
||||
| { type: "opened-file"; value: string }
|
||||
| { type: "folder"; value: string }
|
||||
|
||||
export const TERMINAL_RESULT: MentionResult = {
|
||||
@@ -47,11 +48,22 @@ export function buildMentionResults(query: string, items: Array<FileSearchItem |
|
||||
const results: MentionResult[] = items.map((item) => {
|
||||
if (typeof item === "string") return { type: "file", value: item }
|
||||
if (item.type === "folder") return { type: "folder", value: item.path }
|
||||
if (item.type === "opened-file") return { type: "opened-file", value: item.path }
|
||||
return { type: "file", value: item.path }
|
||||
})
|
||||
return [...getTerminalMentionResult(query), ...(git ? getGitChangesMentionResult(query) : []), ...results]
|
||||
}
|
||||
|
||||
export function filterMentionResults(query: string, items: MentionResult[]): MentionResult[] {
|
||||
const value = query.toLowerCase()
|
||||
if (!value) return items
|
||||
return items.filter((item) => {
|
||||
if (item.type === "terminal") return TERMINAL_MENTION.startsWith(value)
|
||||
if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value)
|
||||
return item.value.toLowerCase().includes(value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the set of mentioned paths against the current text.
|
||||
* Removes any paths that are no longer present in the text as @path mentions.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildTextAfterMentionSelect,
|
||||
buildFileAttachments,
|
||||
buildMentionResults,
|
||||
filterMentionResults,
|
||||
type MentionResult,
|
||||
} from "./file-mention-utils"
|
||||
|
||||
@@ -120,7 +121,7 @@ export function useFileMention(
|
||||
textarea.setSelectionRange(pos, pos)
|
||||
textarea.focus()
|
||||
|
||||
if (result.type === "file" || result.type === "folder")
|
||||
if (result.type === "file" || result.type === "folder" || result.type === "opened-file")
|
||||
setMentionedPaths((prev) => new Set([...prev, result.value]))
|
||||
closeMention()
|
||||
onSelect?.()
|
||||
@@ -133,7 +134,12 @@ export function useFileMention(
|
||||
if (match) {
|
||||
const query = match[1] ?? ""
|
||||
setMentionQuery(query)
|
||||
setMentionResults(buildMentionResults(query, [], git?.() ?? true))
|
||||
setMentionResults((prev) => {
|
||||
const next = filterMentionResults(query, prev)
|
||||
if (next.length) return next
|
||||
return buildMentionResults(query, [], git?.() ?? true)
|
||||
})
|
||||
setMentionIndex(0)
|
||||
requestFileSearch(query)
|
||||
} else {
|
||||
closeMention()
|
||||
|
||||
@@ -334,7 +334,7 @@ export interface ChatCompletionResultMessage {
|
||||
|
||||
export interface FileSearchItem {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
type: "file" | "folder" | "opened-file"
|
||||
}
|
||||
|
||||
export interface FileSearchResultMessage {
|
||||
|
||||
Reference in New Issue
Block a user