Skip to content
Open
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: 6 additions & 0 deletions .changeset/multi-root-file-mentions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": patch
---

Suggest files from every folder in a multi-root VS Code workspace when typing `@`, so folders added through "Add Folder to Workspace..." are mentionable without the file picker. Files outside the session's own project are still read only after the usual approval, and `semantic_search` now reports which root it covers and whether its index was complete, so an empty result is no longer mistaken for missing code.
71 changes: 58 additions & 13 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { renameSession } from "./kilo-provider/rename-session"
import { handleFileSearch } from "./kilo-provider/file-search"
import { handleFileSearch, type SearchRoot } from "./kilo-provider/file-search"
import { handleSessionSearch } from "./kilo-provider/session-search"
import { handleFilePicker } from "./kilo-provider/file-picker"
import { watchFontSizeConfig } from "./kilo-provider/font-size"
Expand Down Expand Up @@ -464,8 +464,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private autoApproveBridge: ReturnType<typeof createAutoApproveBridge> | null = null
private readonly marketplaceRemove = createMarketplaceRemover()

private ignoreController: FileIgnoreController | null = null
private ignoreControllerDir: string | null = null
/** Workspace folders plus any session directories recently asked about. */
private static readonly IGNORE_CONTROLLER_LIMIT = 16
private readonly ignoreControllers = new Map<string, Promise<FileIgnoreController>>()
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
private settingsGeneration = 0
Expand Down Expand Up @@ -2337,6 +2338,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
open: (dir) => this.getOpenTabPaths(dir),
roots: () => this.getWorkspaceRoots(),
post: (msg) => this.postMessage(msg),
})
return
Expand Down Expand Up @@ -5132,18 +5134,55 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

/**
* Get or create a FileIgnoreController for the current workspace directory.
* Reinitializes if the workspace directory has changed.
* Every folder in the editor workspace, as candidate file-mention sources.
*
* File search fans out across these so files in folders added via "Add Folder
* to Workspace..." are mentionable. Fan-out is declined downstream unless the
* session's own directory is one of them.
*/
private getWorkspaceRoots(): SearchRoot[] {
return (vscode.workspace.workspaceFolders ?? []).map((folder) => ({
path: folder.uri.fsPath,
name: folder.name,
}))
}

/**
* Get or create a FileIgnoreController for a workspace directory.
*
* Keyed by directory rather than holding a single controller: multi-root file
* search asks about several roots per keystroke, and a one-entry cache would
* re-read .kilocodeignore from disk on every alternating lookup. Bounded by
* insertion order because session directories, not just workspace folders,
* reach this cache.
*
* A failed init is evicted rather than cached. `initialize()` lets permission
* errors from reading .kilocodeignore propagate, and caching that rejection
* would keep failing every later lookup for the same directory.
*/
private async getIgnoreController(workspaceDir: string): Promise<FileIgnoreController> {
if (this.ignoreController && this.ignoreControllerDir === workspaceDir) {
return this.ignoreController
const cached = this.ignoreControllers.get(workspaceDir)
if (cached) return cached
const pending = (async () => {
const controller = new FileIgnoreController(workspaceDir)
await controller.initialize()
return controller
})()
void pending.catch(() => {
if (this.ignoreControllers.get(workspaceDir) === pending) this.ignoreControllers.delete(workspaceDir)
})
this.ignoreControllers.set(workspaceDir, pending)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[WARNING]: A rejected ignore-controller init is cached forever

The in-flight promise is stored before initialize() completes. The old one-entry cache only assigned after a successful init, so a failure was retried. A rejected entry now sticks until 16 other directories evict it — typical workspaces never reach that.

The next @ search for that root reuses the rejected promise, and combined with Promise.all in handleFileSearch that can disable mentions until reload. Delete the map entry on rejection if it is still this promise.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

while (this.ignoreControllers.size > KiloProvider.IGNORE_CONTROLLER_LIMIT) {
const oldest = this.ignoreControllers.keys().next().value
if (oldest === undefined || oldest === workspaceDir) break
const evicted = this.ignoreControllers.get(oldest)
this.ignoreControllers.delete(oldest)
void evicted?.then(
(controller) => controller.dispose(),
(err) => console.warn("[Kilo New] Failed to dispose ignore controller:", err),
)
}
const controller = new FileIgnoreController(workspaceDir)
await controller.initialize()
this.ignoreController = controller
this.ignoreControllerDir = workspaceDir
return controller
return pending
}

private async gatherEditorContext(dir?: string): Promise<EditorContext> {
Expand Down Expand Up @@ -5569,7 +5608,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.requests.clear()
this.epochs.clear()
this.sessionStatusMap.clear()
this.ignoreController?.dispose()
for (const pending of this.ignoreControllers.values()) {
void pending.then(
(controller) => controller.dispose(),
(err) => console.warn("[Kilo New] Failed to dispose ignore controller:", err),
)
}
this.ignoreControllers.clear()
this.chatAutocomplete?.dispose()
disposeGitChangesTarget()
}
Expand Down
26 changes: 23 additions & 3 deletions packages/kilo-vscode/src/kilo-provider/file-search-items.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" }
export type FileSearchItem = {
path: string
type: "file" | "folder" | "opened-file"
/** Owning workspace folder name, set only when the workspace has more than one folder. */
root?: string
/**
* Path within the owning folder, set only when `path` is absolute. The webview
* ranks the whole `@` menu again, and scoring an absolute path there would let
* the filesystem prefix match on every entry under that folder.
*/
relative?: string
}

const normalize = (p: string) => p.replaceAll("\\", "/")
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
Expand All @@ -23,12 +34,21 @@ export function mergeFileSearchItems(input: {
files: string[]
folders: string[]
open?: Set<string>
/** Path to owning workspace-folder name. Empty in a single-folder workspace, where a badge would say nothing. */
labels?: Map<string, string>
/** Absolute path to its form relative to the owning workspace folder. */
relative?: Map<string, string>
}): FileSearchItem[] {
const query = normalize(input.query).trim().toLowerCase()
const open = new Set([...(input.open ?? [])].map(normalize))
const label = (p: string) => {
const root = input.labels?.get(p)
const rel = input.relative?.get(p)
return { ...(root ? { root } : {}), ...(rel ? { relative: rel } : {}) }
}
const files = input.files.map((p) => {
const path = normalize(p)
return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) }
return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const), ...label(p) }
})
const pinned = files.filter((item) => item.type === "opened-file")
const rest = files.filter((item) => !open.has(item.path))
Expand All @@ -42,7 +62,7 @@ export function mergeFileSearchItems(input: {
return true
})
.map((p, index) => ({
item: { path: normalize(p), type: "folder" as const },
item: { path: normalize(p), type: "folder" as const, ...label(p) },
index,
rank: query ? rank(query, p) : 4,
}))
Expand Down
61 changes: 44 additions & 17 deletions packages/kilo-vscode/src/kilo-provider/file-search-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@ 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)
/**
* `basis` is the path the match is judged on, which is not always the path that
* gets inserted. Entries from other workspace folders are absolute, and scoring
* those in full would let a query match the filesystem prefix — a username or a
* parent directory — on every one of them, and would inflate their depth.
*/
function score(query: string, p: string, priority: number, basis: string) {
const name = base(basis)
return {
p,
label,
path,
depth: depth(p),
basis,
name,
label: fuzzysort.single(query, name),
path: fuzzysort.single(query, basis),
depth: depth(basis),
priority,
}
}

Expand All @@ -30,28 +37,37 @@ function compare(a: ReturnType<typeof score>, b: ReturnType<typeof score>): numb
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
// Only once match quality ties does the owning workspace folder matter, so a
// strong match in an added folder still beats a weak one in the session's own
// project. Reversing these two would bury exact filename matches.
if (a.priority !== b.priority) return a.priority - b.priority

if (a.name.length !== b.name.length) return a.name.length - b.name.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
if (a.basis.length !== b.basis.length) return a.basis.length - b.basis.length
return a.p.localeCompare(b.p)
}

function rankOpen(query: string, paths: string[]): string[] {
type Basis = (p: string) => { priority: number; basis: string }

function rankOpen(query: string, paths: string[], of: Basis): string[] {
if (!query || !paths.length) return paths
const scored: Array<ReturnType<typeof score>> = []
for (const p of paths) {
const result = score(query, p)
const meta = of(p)
const result = score(query, p, meta.priority, meta.basis)
if (result.path) scored.push(result)
}
return scored.sort(compare).map((x) => x.p)
}

function rankBackend(query: string, paths: string[]): string[] {
function rankBackend(query: string, paths: string[], of: Basis): string[] {
if (!query || paths.length <= 1) return paths
return paths
.map((p) => score(query, p))
.map((p) => {
const meta = of(p)
return score(query, p, meta.priority, meta.basis)
})
.sort(compare)
.map((x) => x.p)
}
Expand All @@ -61,18 +77,29 @@ export function mergeFileSearchResults(input: {
backend: string[]
open: Set<string>
active?: string
/**
* Path to owning workspace-folder index, used only to break ties between
* equally good matches. Absent entries rank as the session's own project.
*/
priority?: Map<string, number>
/**
* Path to the root-relative path it should be judged on. Absent entries are
* judged on themselves, which is what single-root search does.
*/
relative?: Map<string, 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 of: Basis = (p) => ({ priority: input.priority?.get(p) ?? 0, basis: input.relative?.get(p) ?? p })
const matched = rankOpen(query, [...open], of)
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)]
return [...tabs, ...rankBackend(query, remaining, of)]
}
Loading
Loading