mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(cli): limit indexing safeguards to home and root
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string>()
|
||||
// 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<string>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
@@ -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<Target> {}
|
||||
|
||||
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,
|
||||
|
||||
@@ -59,7 +59,6 @@ export interface FindInput {
|
||||
readonly follow?: boolean
|
||||
readonly signal?: AbortSignal
|
||||
readonly onEntry?: (entry: Entry) => Effect.Effect<void>
|
||||
readonly validate?: Effect.Effect<void, unknown> // 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",
|
||||
|
||||
@@ -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<void>, 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 {
|
||||
|
||||
@@ -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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<string, boolean>()
|
||||
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.")
|
||||
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,23 +302,15 @@ const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> =
|
||||
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<State>(
|
||||
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<Service, never, Git.Service | EventV2Bridge.Service> =
|
||||
return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof Watcher.Event.Updated>
|
||||
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<Service, never, Git.Service | EventV2Bridge.Service> =
|
||||
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<Service, never, Git.Service | EventV2Bridge.Service> =
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string | undefined>()
|
||||
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)),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [] })")
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user