diff --git a/.changeset/safe-indexing-roots.md b/.changeset/safe-indexing-roots.md index 734b8dec58..d10c67db8d 100644 --- a/.changeset/safe-indexing-roots.md +++ b/.changeset/safe-indexing-roots.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Use on-demand file search and disable background filesystem watchers by default while preserving explicit codebase indexing. +Prevent automatic indexing of home directories and filesystem roots, and show a warning to open a project folder instead. diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 074d9e8b0e..dc4fc77fb1 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -13,7 +13,7 @@ import { RelativePath } from "../schema" import { Flag } from "../flag/flag" // kilocode_change start import * as SearchTarget from "../kilocode/search-target" -import { allowed } from "../kilocode/fff" +import { allowed, message } from "../kilocode/fff" // kilocode_change end export interface Interface { @@ -30,6 +30,7 @@ export const ripgrepLayer = Layer.effect( const fs = yield* FSUtil.Service const location = yield* Location.Service const ripgrep = yield* Ripgrep.Service + const scope = yield* Scope.Scope // kilocode_change start - confine every search to the canonical active Location. const inspect = Effect.fnUntraced(function* (input?: string) { const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie) @@ -39,15 +40,38 @@ export const ripgrepLayer = Layer.effect( const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie) if (root.type !== "directory" || !FSUtil.contains(root.path, target.path)) return yield* Effect.die(new Error("Path escapes the location")) - return { root, target } + return target }) - const list = yield* SearchTarget.listing(fs, ripgrep, location.vcs ? Number.MAX_SAFE_INTEGER : 100_000) + // kilocode_change end + const state = { + files: [] as string[], + directories: [] as string[], + } + const directories = new Set() + // kilocode_change start - never eagerly enumerate a filesystem root. + const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (real && allowed(real)) { + yield* ripgrep + .find({ + cwd: real, + pattern: "*", + limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000, + onEntry: (entry) => + Effect.sync(() => { + state.files.push(entry.path) + const parts = entry.path.split("/") + parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) + state.directories = Array.from(directories) + }), + }) + .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope)) + } // kilocode_change end return Service.of({ glob: (input) => Effect.gen(function* () { // kilocode_change start - const { root, target } = yield* inspect(input.path) + const target = yield* inspect(input.path) const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change end return yield* ripgrep @@ -64,7 +88,7 @@ export const ripgrepLayer = Layer.effect( (entry) => FileSystem.Entry.make({ ...entry, - path: RelativePath.make(path.relative(root.path, path.resolve(cwd, entry.path))), // kilocode_change + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), }), ), ), @@ -74,7 +98,7 @@ export const ripgrepLayer = Layer.effect( grep: (input) => Effect.gen(function* () { // kilocode_change start - const { root, target } = yield* inspect(input.path) + const target = yield* inspect(input.path) const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change end return yield* ripgrep @@ -95,7 +119,7 @@ export const ripgrepLayer = Layer.effect( ...match, entry: FileSystem.Entry.make({ ...match.entry, - path: RelativePath.make(path.relative(root.path, path.resolve(cwd, match.entry.path))), // kilocode_change + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), }), }), ), @@ -105,22 +129,13 @@ export const ripgrepLayer = Layer.effect( }), find: (input) => Effect.gen(function* () { - // kilocode_change start - const { target } = yield* inspect() - if (!allowed(target.path)) return [] - const found = yield* list(target) - const files = found.map((entry) => entry.path) - const directories = new Set() - if (input.type !== "file") { - for (const file of files) { - const parts = file.split("/") - parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) - } - } const items = - input.type === "file" ? files : input.type === "directory" ? [...directories] : [...files, ...directories] - return fuzzysort.go(input.query.trim(), items, { all: true, limit: input.limit ?? 50 }).map((item) => { - // kilocode_change end + input.type === "file" + ? state.files + : input.type === "directory" + ? state.directories + : [...state.files, ...state.directories] + return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => { const relative = item.target const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) return FileSystem.Entry.make({ @@ -128,7 +143,7 @@ export const ripgrepLayer = Layer.effect( type, }) }) - }).pipe(Effect.scoped), // kilocode_change + }), }) }), ) @@ -167,7 +182,7 @@ export const fffLayer = Layer.effect( const make = Effect.uninterruptible( Effect.gen(function* () { const real = yield* fs.realPath(location.directory).pipe(Effect.orDie) - if (!allowed(real)) return yield* Effect.die(new Error("FFF indexing is disabled for filesystem roots.")) + if (!allowed(real)) return yield* Effect.die(new Error(message)) const result = yield* Effect.try({ try: () => Fff.create({ @@ -327,7 +342,7 @@ const layer = Layer.unwrap( const location = yield* Location.Service const fs = yield* FSUtil.Service const real = yield* fs.realPath(location.directory).pipe(Effect.catch(() => Effect.succeed(undefined))) - return real !== undefined && allowed(real) ? fffLayer : ripgrepLayer + return real && allowed(real) ? fffLayer : ripgrepLayer }), ) // kilocode_change end diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 9e4ec02918..e77597e1aa 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -16,6 +16,7 @@ import { Location } from "../location" import { lazy } from "../util/lazy" import { Ignore } from "./ignore" import { Protected } from "./protected" +import { allowed } from "../kilocode/fff" // kilocode_change declare const KILO_LIBC: string | undefined @@ -106,11 +107,13 @@ const layer = Layer.effect( const config = (yield* (yield* Config.Service).entries()) .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) - if (location.vcs && (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER)) { + // kilocode_change start + if (location.vcs && (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) && allowed(location.directory)) { yield* Effect.forkScoped( subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), ) } + // kilocode_change end if (location.vcs?.type === "git") { const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 6365c562c1..3b8736ebee 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -74,7 +74,7 @@ export const Flag = { KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), // kilocode_change KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( - Config.withDefault(true), // kilocode_change + Config.withDefault(false), ), KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), // kilocode_change @@ -126,7 +126,7 @@ export const Flag = { KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change - KILO_DISABLE_FFF: fff === undefined ? true : truthy("KILO_DISABLE_FFF"), // kilocode_change + KILO_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("KILO_DISABLE_FFF"), // kilocode_change get KILO_DISABLE_PROJECT_CONFIG() { return truthy("KILO_DISABLE_PROJECT_CONFIG") diff --git a/packages/core/src/kilocode/fff.ts b/packages/core/src/kilocode/fff.ts index 77828bbc88..6b46227f2a 100644 --- a/packages/core/src/kilocode/fff.ts +++ b/packages/core/src/kilocode/fff.ts @@ -1,13 +1,34 @@ +import { realpathSync } from "node:fs" +import os from "os" import path from "path" +export const message = + "Automatic indexing is disabled in home and filesystem root directories. Open a project folder to enable indexing. File tools remain available." + function root(directory: string, api: typeof path.posix) { if (!api.isAbsolute(directory)) return false return api.normalize(directory) === api.normalize(api.parse(directory).root) } -export function allowed(directory: string) { +function real(directory: string) { + try { + return realpathSync.native(directory) + } catch { + return path.resolve(directory) + } +} + +export function allowed(directory: string, home = (process.env.KILO_TEST_HOME ?? os.homedir()).trim()) { const value = path.win32.normalize(directory) const prefix = "\\\\?\\UNC\\" const windows = value.toUpperCase().startsWith(prefix.toUpperCase()) ? `\\\\${value.slice(prefix.length)}` : value - return !root(directory, path.posix) && !root(windows, path.win32) + if (root(directory, path.posix) || root(windows, path.win32)) return false + const resolved = real(directory) + if (root(resolved, path)) return false + const base = real(home) + return process.platform === "win32" ? resolved.toLowerCase() !== base.toLowerCase() : resolved !== base +} + +export function notices(directory: string) { + return allowed(directory) ? [] : [{ path: directory, message }] } diff --git a/packages/core/src/kilocode/search-target.ts b/packages/core/src/kilocode/search-target.ts index 65669f09fb..8ef90b679a 100644 --- a/packages/core/src/kilocode/search-target.ts +++ b/packages/core/src/kilocode/search-target.ts @@ -1,7 +1,6 @@ import path from "path" -import { Data, Duration, Effect, Option, RcMap } from "effect" +import { Effect, Option } from "effect" import { FSUtil } from "../fs-util" -import type { Ripgrep } from "../ripgrep" import { ToolOutputStore } from "../tool-output-store" export interface Target { @@ -11,8 +10,6 @@ export interface Target { readonly ino: number } -class Key extends Data.Class {} - export const inspect = Effect.fn("SearchTarget.inspect")(function* (fs: FSUtil.Interface, input: string) { const target = yield* fs.realPath(input) const info = yield* fs.stat(target) @@ -31,20 +28,6 @@ export const validate = Effect.fn("SearchTarget.validate")(function* (fs: FSUtil yield* Effect.fail(new Error("Search target changed after approval")) }) -export const listing = Effect.fn("SearchTarget.listing")(function* ( - fs: FSUtil.Interface, - ripgrep: Ripgrep.Interface, - limit: number, -) { - const pending = yield* RcMap.make({ - lookup: (target: Target) => - ripgrep.find({ cwd: target.path, pattern: "*", limit, validate: validate(fs, target) }).pipe(Effect.orDie), - capacity: 1, - idleTimeToLive: Duration.zero, - }) - return (target: Target) => RcMap.get(pending, new Key(target)).pipe(Effect.orDie) -}) - export const managed = Effect.fn("SearchTarget.managed")(function* ( fs: FSUtil.Interface, data: string, diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index ac77422b57..71ee912e24 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -59,7 +59,6 @@ export interface FindInput { readonly follow?: boolean readonly signal?: AbortSignal readonly onEntry?: (entry: Entry) => Effect.Effect - readonly validate?: Effect.Effect // kilocode_change } export interface GlobInput { @@ -229,7 +228,6 @@ const layer = Layer.effect( cwd: input.cwd, limit: input.limit, signal: input.signal, - validate: input.validate, // kilocode_change args: [ "--no-config", "--files", diff --git a/packages/core/test/kilocode/fff.test.ts b/packages/core/test/kilocode/fff.test.ts index d650285f94..2adf19e48e 100644 --- a/packages/core/test/kilocode/fff.test.ts +++ b/packages/core/test/kilocode/fff.test.ts @@ -1,295 +1,127 @@ -import { $ } from "bun" import { describe, expect, test } from "bun:test" import { FileFinder, type InitOptions } from "@ff-labs/fff-bun" import "@opencode-ai/core/filesystem" import { Fff } from "@opencode-ai/core/filesystem/fff.bun" -import type { Interface as Search } from "@opencode-ai/core/filesystem/search" +import { FSUtil } from "@opencode-ai/core/fs-util" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" import fs from "node:fs/promises" import path from "path" import { Cause, Context, Effect, Layer, Scope } from "effect" -import { allowed } from "@opencode-ai/core/kilocode/fff" +import { allowed, message, notices } from "@opencode-ai/core/kilocode/fff" import { Location } from "@opencode-ai/core/location" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" -async function search(fn: (service: Search, directory: string) => Promise, opts: { alias?: boolean } = {}) { - await using tmp = await tmpdir() - const directory = opts.alias ? path.join(tmp.path, "link") : tmp.path - if (opts.alias) { - const target = path.join(tmp.path, "project") - await fs.mkdir(target) - await fs.symlink(target, directory, process.platform === "win32" ? "junction" : "dir") - } - const { FileSystemSearch } = await import("@opencode-ai/core/filesystem/search") - const layer = FileSystemSearch.ripgrepLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(LayerNode.compile(Ripgrep.node)), - Layer.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - ), - ) - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build(layer) - yield* Effect.promise(() => fn(Context.get(context, FileSystemSearch.Service), directory)) - }), - ), - ) -} - describe("FFF scanning boundaries", () => { - test("disables background indexing and watching by default while retaining explicit opt-in", async () => { - const file = new URL("../../src/flag/flag.ts", import.meta.url).href - const script = `import { Flag } from ${JSON.stringify(file)}; import { Effect } from "effect"; console.log(JSON.stringify({ fff: Flag.KILO_DISABLE_FFF, watcher: await Effect.runPromise(Effect.gen(function* () { return yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER })) }))` - for (const value of [undefined, "false"]) { - const child = Bun.spawn([process.execPath, "--eval", script], { - cwd: path.resolve(import.meta.dir, "../.."), - env: { - ...process.env, - KILO_DISABLE_FFF: value, - KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: value, - }, - stdout: "pipe", - stderr: "pipe", - windowsHide: true, - }) - const result = await new Response(child.stdout).json() - expect(await child.exited).toBe(0) - expect(result).toEqual({ fff: value === undefined, watcher: value === undefined }) - } - }) + test.each(["/", "/workspace/..", "C:\\", "D:/", "\\\\server\\share\\", "\\\\?\\C:\\", "\\\\?\\UNC\\server\\share\\"])( + "blocks filesystem root %s", + (directory) => expect(allowed(directory)).toBe(false), + ) - test("rejects POSIX, Windows drive, and UNC roots", () => { - expect(allowed("/")).toBe(false) - expect(allowed("C:\\")).toBe(false) - expect(allowed("D:/")).toBe(false) - expect(allowed("\\\\server\\share\\")).toBe(false) - expect(allowed("\\\\?\\C:\\workspace\\..")).toBe(false) - expect(allowed("\\\\?\\UNC\\server\\share\\")).toBe(false) - }) - - test("allows ordinary project directories", () => { - expect(allowed("/workspace")).toBe(true) - expect(allowed("C:\\workspace")).toBe(true) - expect(allowed("D:/workspace")).toBe(true) - expect(allowed("\\\\server\\share\\workspace")).toBe(true) - }) - - test("only searches safe project directories on request", async () => { - const root = path.parse(process.cwd()).root + test("blocks broad scans and warns for home/root aliases without blocking scoped file tools", async () => { await using tmp = await tmpdir() - const link = path.join(tmp.path, "root") - await fs.symlink(root, link, process.platform === "win32" ? "junction" : "dir") - const create = FileFinder.create + const home = process.env.KILO_TEST_HOME const disabled = Flag.KILO_DISABLE_FFF - Flag.KILO_DISABLE_FFF = true - const calls = { fff: 0, ripgrep: 0 } + const create = FileFinder.create + const calls = { native: 0, walk: 0 } + const root = path.parse(tmp.path).root + const project = path.join(tmp.path, "project") + const alias = path.join(tmp.path, "alias") + const link = path.join(tmp.path, "root") + const kind = process.platform === "win32" ? "junction" : "dir" + await fs.mkdir(project) + await fs.writeFile(path.join(project, "file.ts"), "needle\n") + await fs.symlink(tmp.path, alias, kind) + await fs.symlink(root, link, kind) + process.env.KILO_TEST_HOME = tmp.path FileFinder.create = () => { - calls.fff++ - return { ok: false, error: "FFF must not start at a filesystem root" } + calls.native++ + return { ok: false, error: "unexpected native index" } } - const ripgrep = Layer.succeed( - Ripgrep.Service, - Ripgrep.Service.of({ - find: () => { - calls.ripgrep++ - return Effect.succeed([]) - }, - glob: () => Effect.succeed({ items: [], truncated: false, partial: false }), - grep: () => Effect.succeed({ items: [], truncated: false, partial: false }), - }), - ) - try { - for (const directory of [root, link, tmp.path]) { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search")) - const layer = FileSystemSearch.locationLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(ripgrep), - Layer.provide( - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + expect(allowed(project)).toBe(true) + expect(notices(project)).toEqual([]) + await Effect.runPromise( + Effect.gen(function* () { + const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search")) + const native = yield* Ripgrep.Service + const source = FileSystemSearch.locationLayer.pipe( + Layer.provide( + Layer.succeed(Ripgrep.Service, { + ...native, + find: () => Effect.sync(() => (calls.walk++, [])), + }), + ), + Layer.provide(FSUtil.defaultLayer), + ) + for (const value of [false, true]) { + Flag.KILO_DISABLE_FFF = value + for (const directory of [root, tmp.path, alias, link]) { + expect(allowed(directory)).toBe(false) + expect(notices(directory)).toEqual([{ path: directory, message }]) + const context = yield* Layer.build( + source.pipe( + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + ), ), + Layer.fresh, ), ) - const context = yield* Layer.build(layer) + yield* Effect.yieldNow const service = Context.get(context, FileSystemSearch.Service) - expect(calls).toEqual({ fff: 0, ripgrep: 0 }) - expect(yield* service.find({ query: "", type: "file", limit: 1 })).toEqual([]) - }), - ), - ) - } - expect(calls).toEqual({ fff: 0, ripgrep: 1 }) + expect(yield* service.find({ query: "file" })).toEqual([]) + if (directory !== tmp.path) continue + const path = RelativePath.make("project") + expect((yield* service.glob({ pattern: "*.ts", path })).map((item) => item.path)).toHaveLength(1) + expect((yield* service.grep({ pattern: "needle", path })).map((item) => item.text)).toEqual(["needle\n"]) + } + } + expect(calls).toEqual({ native: 0, walk: 0 }) + yield* Effect.promise(() => fs.unlink(alias).then(() => fs.symlink(project, alias, kind))) + const context = yield* Layer.build( + FileSystemSearch.fffLayer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(alias) }))), + ), + ), + ) + const service = Context.get(context, FileSystemSearch.Service) + for (const directory of [root, tmp.path]) { + yield* Effect.promise(() => fs.unlink(alias).then(() => fs.symlink(directory, alias, kind))) + for (const request of [ + service.find({ query: "" }).pipe(Effect.asVoid), + service.glob({ pattern: "*" }).pipe(Effect.asVoid), + service.grep({ pattern: "needle" }).pipe(Effect.asVoid), + ]) { + const result = yield* Effect.exit(request) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") expect(Cause.pretty(result.cause)).toContain(message) + } + } + expect(calls).toEqual({ native: 0, walk: 0 }) + }).pipe(Effect.provide(LayerNode.compile(Ripgrep.node)), Effect.scoped), + ) } finally { FileFinder.create = create Flag.KILO_DISABLE_FFF = disabled + if (home === undefined) delete process.env.KILO_TEST_HOME + if (home !== undefined) process.env.KILO_TEST_HOME = home } }) }) -describe("on-demand file search", () => { - test("finds files and directories without a warmup scan", async () => { - await search(async (service, directory) => { - await fs.mkdir(path.join(directory, "src")) - await fs.writeFile(path.join(directory, "src", "alpha.ts"), "export const alpha = 1\n") - await fs.writeFile(path.join(directory, "beta.ts"), "export const beta = 2\n") - - const files = await Effect.runPromise(service.find({ query: "", type: "file" })) - expect(files.map((item) => String(item.path)).sort()).toEqual(["beta.ts", "src/alpha.ts"]) - const match = await Effect.runPromise(service.find({ query: " alpts ", type: "file", limit: 1 })) - expect(match.map((item) => String(item.path))).toEqual(["src/alpha.ts"]) - const directories = await Effect.runPromise(service.find({ query: "src", type: "directory" })) - expect(directories).toEqual([{ path: RelativePath.make(`src${path.sep}`), type: "directory" }]) - const mixed = await Effect.runPromise(service.find({ query: "" })) - expect(mixed.map((item) => item.type).sort()).toEqual(["directory", "file", "file"]) - }) - }) - - test("sees added, renamed, and deleted files on the next request", async () => { - await search(async (service, directory) => { - expect(await Effect.runPromise(service.find({ query: "", type: "file" }))).toEqual([]) - await fs.writeFile(path.join(directory, "added.ts"), "export const added = 1\n") - expect( - (await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)), - ).toEqual(["added.ts"]) - await fs.rename(path.join(directory, "added.ts"), path.join(directory, "renamed.ts")) - expect( - (await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)), - ).toEqual(["renamed.ts"]) - await fs.unlink(path.join(directory, "renamed.ts")) - expect(await Effect.runPromise(service.find({ query: "", type: "file" }))).toEqual([]) - }) - }) - - test("reloads ignore rules and keeps hidden files out of file suggestions", async () => { - await search(async (service, directory) => { - await $`git init --quiet`.cwd(directory).quiet() - await fs.writeFile(path.join(directory, ".gitignore"), "ignored.ts\n") - await fs.writeFile(path.join(directory, "ignored.ts"), "export const ignored = 1\n") - await fs.writeFile(path.join(directory, ".hidden.ts"), "export const hidden = 2\n") - await fs.writeFile(path.join(directory, "visible.ts"), "export const visible = 3\n") - expect( - (await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)), - ).toEqual(["visible.ts"]) - await fs.writeFile(path.join(directory, ".gitignore"), "") - expect( - (await Effect.runPromise(service.find({ query: "", type: "file" }))).map((item) => String(item.path)).sort(), - ).toEqual(["ignored.ts", "visible.ts"]) - }) - }) - - test("keeps project-relative search results through a symlinked workspace", async () => { - await search( - async (service, directory) => { - await fs.mkdir(path.join(directory, "src")) - await fs.writeFile(path.join(directory, "src", "match.ts"), "needle\n") - const files = await Effect.runPromise(service.find({ query: "match", type: "file" })) - const glob = await Effect.runPromise(service.glob({ pattern: "*.ts", path: RelativePath.make("src") })) - const grep = await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") })) - expect(files.map((item) => String(item.path))).toEqual(["src/match.ts"]) - expect(glob.map((item) => String(item.path))).toEqual([path.join("src", "match.ts")]) - expect(grep.map((item) => String(item.entry.path))).toEqual([path.join("src", "match.ts")]) - }, - { alias: true }, - ) - }) - - test("keeps glob and grep results current and scoped", async () => { - await search(async (service, directory) => { - await fs.mkdir(path.join(directory, "src")) - const input = { pattern: "**/*.ts", path: RelativePath.make("src") } - expect(await Effect.runPromise(service.glob(input))).toEqual([]) - await fs.writeFile(path.join(directory, "src", "match.ts"), "needle\n") - await fs.writeFile(path.join(directory, "outside.ts"), "needle\n") - expect((await Effect.runPromise(service.glob(input))).map((item) => String(item.path))).toEqual([ - path.join("src", "match.ts"), - ]) - const matches = await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") })) - expect(matches.map((item) => String(item.entry.path))).toEqual([path.join("src", "match.ts")]) - await fs.writeFile(path.join(directory, "src", "match.ts"), "changed\n") - expect(await Effect.runPromise(service.grep({ pattern: "needle", path: RelativePath.make("src") }))).toEqual([]) - }) - }) -}) - describe("FFF lifecycle", () => { - test("rechecks a changed symlink before creating a picker and retries with the canonical path", async () => { - await using tmp = await tmpdir() - const project = path.join(tmp.path, "project") - const link = path.join(tmp.path, "link") - const type = process.platform === "win32" ? "junction" : "dir" - await fs.mkdir(project) - await fs.symlink(project, link, type) - const create = FileFinder.create - const calls: InitOptions[] = [] - FileFinder.create = (opts) => { - calls.push(opts) - return { ok: false, error: "native creation intercepted" } - } - - try { - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search")) - const layer = FileSystemSearch.fffLayer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(link) }))), - ), - ) - const context = yield* Layer.build(layer) - const service = Context.get(context, FileSystemSearch.Service) - expect(calls).toEqual([]) - yield* Effect.promise(async () => { - await fs.unlink(link) - await fs.symlink(path.parse(project).root, link, type) - }) - - for (const effect of [ - service.find({ query: "", type: "file", limit: 1 }).pipe(Effect.asVoid), - service.glob({ pattern: "*", limit: 1 }).pipe(Effect.asVoid), - service.grep({ pattern: "needle", limit: 1 }).pipe(Effect.asVoid), - ]) { - const result = yield* Effect.exit(effect) - expect(result._tag).toBe("Failure") - if (result._tag === "Failure") { - expect(Cause.pretty(result.cause)).toContain("FFF indexing is disabled for filesystem roots.") - } - } - expect(calls).toEqual([]) - - yield* Effect.promise(async () => { - await fs.unlink(link) - await fs.symlink(project, link, type) - }) - yield* Effect.exit(service.find({ query: "", type: "file", limit: 1 })) - expect(calls).toHaveLength(1) - expect(calls[0].basePath).toBe(project) - }), - ), - ) - } finally { - FileFinder.create = create - } - }) - test("retries a failed first search and reuses one picker", async () => { if (!Fff.available()) return const dir = await tmpdir() - expect(allowed(dir.path)).toBe(true) const create = FileFinder.create const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined } try { diff --git a/packages/core/test/kilocode/search-target.test.ts b/packages/core/test/kilocode/search-target.test.ts index 51b6e90cd9..08d53e1073 100644 --- a/packages/core/test/kilocode/search-target.test.ts +++ b/packages/core/test/kilocode/search-target.test.ts @@ -1,12 +1,11 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { Effect, Exit, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util" import * as SearchTarget from "@opencode-ai/core/kilocode/search-target" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { RelativePath } from "@opencode-ai/core/schema" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -30,84 +29,11 @@ describe("search target confinement", () => { yield* Effect.promise(() => fs.mkdir(target)) yield* Effect.promise(() => fs.writeFile(path.join(target, "secret.txt"), "secret")) - const ripgrep = yield* Ripgrep.Service - const input = { cwd: target, pattern: "secret", limit: 10, validate: SearchTarget.validate(fsys, approved) } - expect(Exit.isFailure(yield* ripgrep.find(input).pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* ripgrep.glob(input).pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* ripgrep.grep(input).pipe(Effect.exit))).toBe(true) - }), - ), - ) + const result = yield* (yield* Ripgrep.Service) + .grep({ cwd: target, pattern: "secret", limit: 10, validate: SearchTarget.validate(fsys, approved) }) + .pipe(Effect.exit) - it.live("shares active listings and drops them when requests finish", () => - withTmp((tmp) => - Effect.gen(function* () { - const fsys = yield* FSUtil.Service - const ripgrep = yield* Ripgrep.Service - const target = yield* SearchTarget.inspect(fsys, tmp) - const started = yield* Deferred.make() - const release = yield* Deferred.make() - let calls = 0 - const list = yield* SearchTarget.listing( - fsys, - { - ...ripgrep, - find: () => - Effect.gen(function* () { - calls++ - yield* Deferred.succeed(started, undefined) - yield* Deferred.await(release) - return [{ path: RelativePath.make(`scan-${calls}.ts`), type: "file" as const }] - }), - }, - 100_000, - ) - expect(calls).toBe(0) - const one = yield* Scope.make() - const two = yield* Scope.make() - const first = yield* Scope.provide(one)(list(target)).pipe(Effect.forkChild) - yield* Deferred.await(started) - const second = yield* Scope.provide(two)(list({ ...target })).pipe(Effect.forkChild) - yield* Deferred.succeed(release, undefined) - const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) - expect(calls).toBe(1) - expect(results.map((items) => String(items[0].path))).toEqual(["scan-1.ts", "scan-1.ts"]) - const replaced = { ...target, ino: target.ino === 0 ? 1 : 0 } - expect(Exit.isFailure(yield* list(replaced).pipe(Effect.scoped, Effect.exit))).toBe(true) - expect(calls).toBe(1) - yield* Scope.close(one, Exit.void) - yield* Scope.close(two, Exit.void) - const fresh = yield* list(target).pipe(Effect.scoped) - expect(calls).toBe(2) - expect(String(fresh[0].path)).toBe("scan-2.ts") - }), - ), - ) - - it.live("interrupts an abandoned file scan", () => - withTmp((tmp) => - Effect.gen(function* () { - const fsys = yield* FSUtil.Service - const ripgrep = yield* Ripgrep.Service - const target = yield* SearchTarget.inspect(fsys, tmp) - const started = yield* Deferred.make() - const stopped = yield* Deferred.make() - const list = yield* SearchTarget.listing( - fsys, - { - ...ripgrep, - find: () => - Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)), - ), - }, - 100_000, - ) - const pending = yield* list(target).pipe(Effect.scoped, Effect.forkChild) - yield* Deferred.await(started) - yield* Fiber.interrupt(pending) - yield* Deferred.await(stopped).pipe(Effect.timeout("5 seconds")) + expect(Exit.isFailure(result)).toBe(true) }), ), ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 22bcc1cecb..67048e6bc1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -8,6 +8,7 @@ import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { Flag } from "@opencode-ai/core/flag/flag" +import { notices } from "@opencode-ai/core/kilocode/fff" // kilocode_change import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc @@ -479,7 +480,7 @@ const layer = Layer.effect( const loadInstanceState = Effect.fn("Config.loadInstanceState")( function* (ctx: InstanceContext) { // kilocode_change start - warning accumulator and legacy Kilo config - const warnings: Warning[] = [] + const warnings: Warning[] = notices(ctx.directory) // Untrusted project config may only read files inside this root (worktree, or directory for non-git projects). const projectRoot = ctx.worktree === "/" ? ctx.directory : ctx.worktree const auth = yield* authSvc.all().pipe(Effect.orDie) diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts index 57a19b995c..c7d23221c8 100644 --- a/packages/opencode/src/kilocode/bootstrap.ts +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -80,6 +80,16 @@ export namespace KilocodeBootstrap { Effect.sync(() => log.warn("session export bootstrap failed", { err: Cause.squash(cause) })), ), ) + if (process.env["KILO_PLATFORM"] !== "vscode") { + yield* EffectBridge.fromPromise(() => + import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()), + ).pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.warn("indexing bootstrap failed", { err: Cause.squash(cause) })), + ), + Effect.forkDetach, + ) + } }) return Service.of({ init }) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 3acd75a5a5..a2ce6c8bd0 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -6,7 +6,6 @@ */ import { createEffect, createMemo, on, onCleanup } from "solid-js" -import { reconcile } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" import { TextAttributes } from "@opentui/core" import * as Clipboard from "@tui/clipboard" @@ -27,9 +26,6 @@ import { useIndexingWarnings } from "@/kilocode/cli/cmd/tui/indexing-warning" import { KiloTerminalTitle } from "./terminal-title" import type { KiloTitleIcon } from "./title-icon" import { Session as SessionApi } from "@/session/session" -import { useProject } from "@tui/context/project" -import * as Branch from "./branch-refresh" -import * as Log from "@opencode-ai/core/util/log" // Re-export so upstream can render the route without importing directly export { KiloClawView } from "@/kilocode/claw/view" @@ -84,27 +80,14 @@ export function useSessionEffects(deps: { const pty = process.env.KILO_PTY_ID const viewerId = crypto.randomUUID() const renderer = useRenderer() - const project = useProject() const session = createMemo(() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined)) let active = true const meta = { prev: "" } - const log = Log.create({ service: "tui-branch" }) - const branch = Branch.create({ - get: (input) => deps.sdk.client.vcs.get(input, { throwOnError: true }), - apply: (data) => deps.sync.set("vcs", reconcile(data)), - scope: () => ({ - workspace: project.workspace.current(), - directory: project.instance.directory() || deps.sdk.directory, - project: project.project() ?? undefined, - }), - ready: () => deps.sync.data.vcs !== undefined, - }) function send() { const id = session() const ids = id ? [id] : [] deps.sdk.client.session.viewed({ viewer: { id: viewerId, active }, attached: ids, visible: ids }).catch(() => {}) - if (active) void branch.refresh().catch((err) => log.warn("branch refresh failed", { err })) } createEffect(() => send()) @@ -152,7 +135,6 @@ export function useSessionEffects(deps: { ) onCleanup(() => { - branch.dispose() renderer.off("focus", onFocus) renderer.off("blur", onBlur) offConnected() diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/branch-refresh.ts b/packages/opencode/src/kilocode/cli/cmd/tui/branch-refresh.ts deleted file mode 100644 index b8529bc1be..0000000000 --- a/packages/opencode/src/kilocode/cli/cmd/tui/branch-refresh.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { VcsInfo } from "@kilocode/sdk/v2" - -type Scope = { - workspace?: string - directory?: string - project?: string -} - -type Input = { - get: (input: { workspace?: string; directory?: string }) => Promise<{ data?: VcsInfo }> - apply: (data: VcsInfo) => void - scope: () => Scope - ready: () => boolean -} - -export function create(input: Input) { - const state = { version: 0, disposed: false } - - async function refresh() { - if (state.disposed) return - const version = ++state.version - const scope = input.scope() - if (!scope.directory || !scope.project || !input.ready()) return - - const route = scope.workspace ? { workspace: scope.workspace } : { directory: scope.directory } - const result = await input.get(route) - const current = input.scope() - if ( - !result.data || - !input.ready() || - state.disposed || - version !== state.version || - current.workspace !== scope.workspace || - current.directory !== scope.directory || - current.project !== scope.project - ) - return - - input.apply(result.data) - } - - function dispose() { - state.disposed = true - state.version += 1 - } - - return { refresh, dispose } -} diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts index 2d4e69441a..628469d918 100644 --- a/packages/opencode/src/kilocode/indexing.ts +++ b/packages/opencode/src/kilocode/indexing.ts @@ -8,7 +8,7 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { IndexingStatus, disabledIndexingStatus } from "@kilocode/kilo-indexing/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" -import { allowed } from "@opencode-ai/core/kilocode/fff" +import { allowed, message } from "@opencode-ai/core/kilocode/fff" import { Instance } from "@/kilocode/instance" import { Bus } from "@/bus" import { Config } from "@/config/config" @@ -34,7 +34,7 @@ const consent = new Map() const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.") const noWorkspace = () => disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.") -const unsafeRoot = () => disabledIndexingStatus("Codebase indexing is disabled for filesystem roots.") +const unsafeRoot = () => disabledIndexingStatus(message) const noConsent = () => disabledIndexingStatus("Codebase indexing is disabled until you enable it for this project in Kilo Settings.") diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index 192d03f647..ed5218db2d 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -57,8 +57,6 @@ export namespace KilocodeWatcher { return Service.of({ init: Effect.fn("KilocodeWatcher.init")(function* () { - if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => true)))) - return const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git" || active.has(ctx.directory)) return @@ -79,5 +77,12 @@ export namespace KilocodeWatcher { }), ) - export const defaultLayer = layer.pipe(Layer.provide(locationServiceMapLayer)) + // Gate the whole layer so LocationServiceMap is only warmed for clients that consume branch-update events. + export const defaultLayer = Layer.unwrap( + Effect.gen(function* () { + if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false)))) + return Layer.succeed(Service, Service.of({ init: () => Effect.void })) + return layer.pipe(Layer.provide(locationServiceMapLayer)) + }), + ) } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 19b51a9ae6..eca56c0501 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -302,23 +302,15 @@ const layer: Layer.Layer = const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope - // kilocode_change start - const refresh = Effect.fnUntraced(function* (value: State, directory: string) { - const next = yield* git.branch(directory) - if (next !== value.current) { - value.current = next - yield* events.publish(Event.BranchUpdated, { branch: next }) - } - return next - }) - // kilocode_change end - const state = yield* InstanceState.make( Effect.fn("Vcs.state")(function* (ctx) { if (ctx.project.vcs !== "git") { return { current: undefined, root: undefined } } + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { concurrency: 2, }) @@ -329,7 +321,13 @@ const layer: Layer.Layer = return Effect.void const data = event.data as EventV2.Data if (!data.file.endsWith("HEAD")) return Effect.void - return refresh(value, ctx.directory) // kilocode_change + return Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + value.current = next + yield* events.publish(Event.BranchUpdated, { branch: next }) + } + }) }) yield* Effect.addFinalizer(() => unsubscribe) @@ -341,14 +339,9 @@ const layer: Layer.Layer = init: Effect.fn("Vcs.init")(function* () { yield* InstanceState.get(state).pipe(Effect.forkIn(scope)) }), - // kilocode_change start branch: Effect.fn("Vcs.branch")(function* () { - const value = yield* InstanceState.get(state) - const ctx = yield* InstanceState.context - if (ctx.project.vcs !== "git") return - return yield* refresh(value, ctx.directory) + return yield* InstanceState.use(state, (x) => x.current) }), - // kilocode_change end defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), @@ -386,8 +379,7 @@ const layer: Layer.Layer = } if (!value.root) return [] - const current = yield* refresh(value, ctx.directory) // kilocode_change - if (current && current === value.root.name) return [] // kilocode_change + if (value.current && value.current === value.root.name) return [] const ref = yield* git.mergeBase(ctx.directory, value.root.ref) if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref, options) diff --git a/packages/opencode/test/kilocode/cli/tui/branch-refresh.test.ts b/packages/opencode/test/kilocode/cli/tui/branch-refresh.test.ts deleted file mode 100644 index 22bc64626d..0000000000 --- a/packages/opencode/test/kilocode/cli/tui/branch-refresh.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { VcsInfo } from "@kilocode/sdk/v2" -import { create } from "../../../../src/kilocode/cli/cmd/tui/branch-refresh" - -function setup(workspace?: string) { - const state = { - scope: { workspace, directory: "/repo", project: "project" }, - vcs: { branch: "main", default_branch: "main" } as VcsInfo | undefined, - } - const calls: Array<{ workspace?: string; directory?: string }> = [] - const pending = Promise.withResolvers<{ data?: VcsInfo }>() - const refresh = create({ - get: async (route) => { - calls.push(route) - return pending.promise - }, - apply: (data) => (state.vcs = data), - scope: () => state.scope, - ready: () => state.vcs !== undefined, - }) - return { state, calls, pending, refresh } -} - -describe("TUI branch refresh", () => { - test("waits for bootstrap and then applies the complete VCS snapshot", async () => { - const value = setup() - value.state.vcs = undefined - await value.refresh.refresh() - expect(value.calls).toEqual([]) - - value.state.vcs = { branch: "main", default_branch: "main" } - const run = value.refresh.refresh() - value.pending.resolve({ data: { branch: "feature", default_branch: "main" } }) - await run - expect(value.state.vcs).toEqual({ branch: "feature", default_branch: "main" }) - }) - - test.each(["ws", undefined])( - "routes %s refreshes and updates metadata when the branch is unchanged", - async (workspace) => { - const value = setup(workspace) - const run = value.refresh.refresh() - expect(value.calls).toEqual([workspace ? { workspace } : { directory: "/repo" }]) - value.pending.resolve({ data: { branch: "main", default_branch: "develop" } }) - await run - expect(value.state.vcs).toEqual({ branch: "main", default_branch: "develop" }) - }, - ) - - test("ignores responses after the scope changes", async () => { - const value = setup("ws-a") - const run = value.refresh.refresh() - value.state.scope = { workspace: "ws-b", directory: "/repo/b", project: "project" } - value.pending.resolve({ data: { branch: "stale", default_branch: "main" } }) - await run - expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" }) - }) - - test("ignores responses after disposal", async () => { - const value = setup() - const run = value.refresh.refresh() - value.refresh.dispose() - value.pending.resolve({ data: { branch: "stale", default_branch: "main" } }) - await run - await value.refresh.refresh() - expect(value.calls).toHaveLength(1) - expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" }) - }) - - test("keeps the current VCS snapshot when the response has no data", async () => { - const value = setup() - const run = value.refresh.refresh() - value.pending.resolve({}) - await run - expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" }) - }) -}) diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts index 3487490958..b910db0d7b 100644 --- a/packages/opencode/test/kilocode/indexing-startup.test.ts +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -8,6 +8,8 @@ import { normalizeIndexingStatus } from "@kilocode/kilo-indexing/status" import type { Config } from "../../src/config/config" import { GlobalBus } from "../../src/bus/global" import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { Global } from "@opencode-ai/core/global" +import { message } from "@opencode-ai/core/kilocode/fff" import { WorkspaceContext } from "../../src/control-plane/workspace-context" import { KiloIndexing, IndexingModelError } from "../../src/kilocode/indexing" import { indexingWarningKey } from "../../src/kilocode/indexing-warning" @@ -533,35 +535,37 @@ describe("indexing startup degradation", () => { }) }) - test("does not allocate an indexing worker for a filesystem root", async () => { + test("warns for home/root workspaces and aliases without allocating an indexing worker", async () => { const created: string[] = [] - IndexingWorker.override((directory, root, hooks) => { + IndexingWorker.override((directory) => { created.push(directory) - return inline(directory, root, hooks) + throw new Error("unsafe workspaces must not allocate an indexing worker") }) - const root = path.parse(process.cwd()).root await using tmp = await tmpdir() - const link = process.platform === "win32" ? undefined : path.join(tmp.path, "root") - if (link) await fs.symlink(root, link) - - for (const directory of [root, link].filter((item): item is string => item !== undefined)) { - await provideTestInstance({ - directory, - fn: async () => { - const status = await KiloIndexing.current() - - expect(status).toMatchObject({ - state: "Disabled", - message: "Codebase indexing is disabled for filesystem roots.", - }) - expect(await KiloIndexing.available()).toBe(false) - expect(KiloIndexing.ready()).toBe(false) - expect(await KiloIndexing.search("filesystem root")).toEqual([]) - expect(created).toEqual([]) - }, - }) + const app = Server.Default().app + for (const target of [path.parse(process.cwd()).root, Global.Path.home]) { + const link = path.join(tmp.path, target === Global.Path.home ? "home" : "root") + await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir") + for (const directory of [target, link]) { + await provideTestInstance({ + directory, + fn: async () => { + expect(await KiloIndexing.current()).toMatchObject({ state: "Disabled", message }) + expect(await KiloIndexing.available()).toBe(false) + expect(KiloIndexing.ready()).toBe(false) + expect(await KiloIndexing.search("filesystem root")).toEqual([]) + const warnings = await app.request("/config/warnings", { headers: { "x-kilo-directory": directory } }) + expect(warnings.status).toBe(200) + expect(await warnings.json()).toContainEqual(expect.objectContaining({ message })) + expect(created).toEqual([]) + }, + }) + } } + const warnings = await app.request("/config/warnings", { headers: { "x-kilo-directory": tmp.path } }) + expect(warnings.status).toBe(200) + expect(await warnings.json()).not.toContainEqual(expect.objectContaining({ message })) }) test.each([false, true])("handles removed directories with no-workspace flag %s", async (disabled) => { diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index 791b5369ef..d03ce98002 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -1,5 +1,4 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect, test } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ConfigProvider, Context, Deferred, Effect, Fiber, Layer, LayerMap } from "effect" @@ -8,13 +7,11 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { InstanceRef } from "../../src/effect/instance-ref" import { disposeInstance } from "../../src/effect/instance-registry" import { Git } from "../../src/git" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Vcs } from "../../src/project/vcs" import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { KilocodeWatcher } from "../../src/kilocode/watcher" import type { InstanceContext } from "../../src/project/instance-context" -import { TestInstance, tmpdirScoped } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services" @@ -28,9 +25,6 @@ const config = ConfigProvider.layerAdd(ConfigProvider.fromUnknown({ KILO_EXPERIM asPrimary: true, }) const it = testEffect(layer.pipe(Layer.provideMerge(config))) -const direct = testEffect( - LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, CrossSpawnSpawner.node])), -) // The watcher is unreliable on Windows CI, so this test only runs on unix. const live = process.platform === "win32" ? it.live.skip : it.live @@ -97,43 +91,6 @@ live( 20_000, ) -direct.instance( - "refreshes branch reads, events, and diffs without native watchers", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const vcs = yield* Vcs.Service - const events = yield* EventV2Bridge.Service - const git = Effect.fn(function* (args: string[]) { - const result = yield* Git.Service.use((git) => git.run(args, { cwd: test.directory })) - expect(result.exitCode).toBe(0) - }) - yield* git(["branch", "-M", "main"]) - expect(yield* vcs.branch()).toBe("main") - - const updated = yield* Deferred.make() - const off = yield* events.listen((event) => { - if (event.type === Vcs.Event.BranchUpdated.type) - Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch)) - return Effect.void - }) - yield* Effect.addFinalizer(() => off) - yield* git(["switch", "-c", "feature"]) - expect(yield* vcs.branch()).toBe("feature") - expect(yield* awaitWithTimeout(Deferred.await(updated), "timed out waiting for branch update")).toBe("feature") - - yield* git(["switch", "main"]) - expect(yield* vcs.branch()).toBe("main") - yield* git(["switch", "feature"]) - yield* Effect.promise(() => Bun.write(`${test.directory}/branch.txt`, "branch\n")) - yield* git(["add", "branch.txt"]) - yield* git(["commit", "--no-gpg-sign", "-m", "branch change"]) - const diff = yield* vcs.diff("branch") - expect(diff.find((item) => item.file === "branch.txt")).toMatchObject({ status: "added" }) - }), - { git: true }, -) - test.serial( "isolates location lifetimes between instances", async () => { @@ -166,12 +123,6 @@ test.serial( } as InstanceContext), ) - yield* init(one).pipe( - Effect.provide( - ConfigProvider.layer(ConfigProvider.fromUnknown({ KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" })), - ), - ) - expect(warmed.size).toBe(0) yield* init(one) yield* init(one) yield* init(two) @@ -188,7 +139,7 @@ test.serial( expect(invalidated).toEqual([one, two]) }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), TestConsole.layer, config)), + Effect.provide(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), TestConsole.layer)), ), ) }, diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 5f1ad7e50c..6c66d6446d 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -454,9 +454,11 @@ describe("kilocode tool registry indexing", () => { } }) - test("does not start indexing during session bootstrap", async () => { + test("logs indexing bootstrap failures without blocking session bootstrap", async () => { const platform = process.env["KILO_PLATFORM"] process.env["KILO_PLATFORM"] = "cli" + const logger = Log.create({ service: "kilocode-bootstrap" }) + const err = new Error("indexing init failed") const calls: string[] = [] const sessions = Layer.succeed( KiloSessions.Service, @@ -481,7 +483,8 @@ describe("kilocode tool registry indexing", () => { const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface) const provider = Layer.succeed(Provider.Service, {} as Provider.Interface) const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void })) - const indexing = spyOn(KiloIndexing, "init").mockResolvedValue(undefined) + const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err) + const warn = spyOn(logger, "warn").mockImplementation(() => {}) try { await Effect.runPromise( @@ -495,11 +498,13 @@ describe("kilocode tool registry indexing", () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(calls).toEqual(["sessions"]) - expect(indexing).not.toHaveBeenCalled() + expect(indexing).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("indexing bootstrap failed", { err }) } finally { if (platform === undefined) delete process.env["KILO_PLATFORM"] else process.env["KILO_PLATFORM"] = platform indexing.mockRestore() + warn.mockRestore() } }) }) diff --git a/packages/opencode/test/kilocode/tui-session-presence-contract.test.ts b/packages/opencode/test/kilocode/tui-session-presence-contract.test.ts index a3cc4813ee..0678f9696e 100644 --- a/packages/opencode/test/kilocode/tui-session-presence-contract.test.ts +++ b/packages/opencode/test/kilocode/tui-session-presence-contract.test.ts @@ -69,36 +69,8 @@ describe("TUI session presence contract", () => { expect(cleanup()).toContain("offConnected()") }) - test("refreshes branch state from the existing session effect triggers", () => { - const body = flat(effects()) - expect(body).toContain("const branch = Branch.create") - expect(body).toContain("if (active) void branch.refresh().catch") - expect(body).toContain("get: (input) => deps.sdk.client.vcs.get(input, { throwOnError: true })") - expect(body).toContain('apply: (data) => deps.sync.set("vcs", reconcile(data))') - expect(body).toContain("ready: () => deps.sync.data.vcs !== undefined") - }) - - test("file autocomplete cancels superseded and closed requests", () => { - const source = flat( - fs.readFileSync(path.resolve(import.meta.dir, "../../../tui/src/component/prompt/autocomplete.tsx"), "utf8"), - ) - expect(source).toContain("visible: store.visible") - expect(source).toContain("onCleanup(() => request?.abort())") - expect(source).toContain("request?.abort()") - expect(source).toContain("{ signal: controller.signal }") - expect(source).toContain("if (controller.signal.aborted || !result) return []") - }) - - test("scopes branch events by directory when there is no workspace ID", () => { - const source = fs.readFileSync(path.resolve(import.meta.dir, "../../../tui/src/context/sync.tsx"), "utf8") - expect(flat(source)).toContain( - "workspace === project.workspace.current() && (workspace !== undefined || directory === project.instance.directory())", - ) - }) - test("cleanup removes focus/blur listeners and sends a final inactive empty snapshot", () => { const tail = cleanup() - expect(tail).toContain("branch.dispose()") expect(tail).toContain('renderer.off("focus", onFocus)') expect(tail).toContain('renderer.off("blur", onBlur)') expect(flat(tail)).toContain(".viewed({ viewer: { id: viewerId, active: false }, attached: [], visible: [] })") diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 7d97013ff8..489e6ae76d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -321,40 +321,22 @@ export function Autocomplete(props: { insertPart(filename, part) } - // kilocode_change start - let request: AbortController | undefined - onCleanup(() => request?.abort()) - // kilocode_change end - const [files] = createResource( - () => ({ query: search(), location: location(), visible: store.visible }), // kilocode_change + () => ({ query: search(), location: location() }), async (input) => { - request?.abort() // kilocode_change - if (!input.visible || input.visible === "/") return [] // kilocode_change + if (!store.visible || store.visible === "/") return [] if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(input.query ?? "") - // kilocode_change start - const controller = new AbortController() - request = controller - const result = await sdk.client.v2.fs - .find( - { - query: baseQuery, - limit: "20", - location: { - directory: input.location?.directory, - workspace: input.location?.workspaceID ?? project.workspace.current(), - }, - }, - { signal: controller.signal }, - ) - .catch((err) => { - if (controller.signal.aborted) return - throw err - }) - if (controller.signal.aborted || !result) return [] - // kilocode_change end + // Get files from SDK + const result = await sdk.client.v2.fs.find({ + query: baseQuery, + limit: "20", + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? project.workspace.current(), + }, + }) const options: AutocompleteOption[] = [] diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 001bc07843..4111cdf904 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -614,15 +614,10 @@ export const { } case "vcs.branch.updated": { - // kilocode_change start - if ( - workspace === project.workspace.current() && - (workspace !== undefined || directory === project.instance.directory()) - ) { - vcsVersion += 1 + if (workspace === project.workspace.current()) { + vcsVersion += 1 // kilocode_change setStore("vcs", { branch: event.properties.branch }) } - // kilocode_change end break } // kilocode_change start