mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
refactor(agent-manager): move project domain into src/agent-manager/project
The project-domain modules sat flat in src/agent-manager next to the shared capability layer (GitOps, WorktreeStateManager, pollers, diff helpers), which made the layering invisible: anything could import anything and the folder gave no hint about what belongs to per-project state versus shared infrastructure. Moves the files whose entire reason to exist is per-project state and routing into src/agent-manager/project/: context (split from the old project-context.ts into context.ts for the single-project scope and contexts.ts for the panel-level coordinator), init, paths, registry, route, scope, pollers, messages, session-view, state-gate, and wiring. Shared capabilities stay at the parent level; the domain imports them across the boundary (../GitOps etc.), the same pattern the webview project/ directory already uses. Rename-only aside from the context split and import path updates; no barrel module.
This commit is contained in:
@@ -165,7 +165,7 @@ import { configFeatures } from "./features"
|
||||
import { fetchSnapshot } from "./kilo-provider/config-snapshot"
|
||||
import { createAutoApproveBridge } from "./kilo-provider/auto-approve"
|
||||
import type { KiloProviderOptions } from "./kilo-provider/options"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project-route"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project/route"
|
||||
import { indexingConsentStore, registeredProjects } from "./indexing-consent"
|
||||
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
|
||||
import { fetchImageModels } from "./image-generation/models"
|
||||
@@ -182,7 +182,7 @@ import {
|
||||
type ConfigProject,
|
||||
type ConfigTarget,
|
||||
} from "./kilo-provider/config-bindings"
|
||||
import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project-paths"
|
||||
import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project/paths"
|
||||
import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
|
||||
import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings"
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import * as path from "path"
|
||||
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils"
|
||||
import { samePath } from "./project-paths"
|
||||
import { samePath } from "./project/paths"
|
||||
import { resolveLocalDiffTarget } from "../diff/shared/target"
|
||||
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
|
||||
import { isAbsolutePath } from "../path-utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
|
||||
import { handleSection } from "./section-handler"
|
||||
import { STATE_GATED } from "./state-gate"
|
||||
import { STATE_GATED } from "./project/state-gate"
|
||||
import {
|
||||
addSessionToLifecycleWorktree,
|
||||
closeLifecycleSession,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { normalizeBaseBranch } from "./base-branch"
|
||||
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import { createPollers, type ProjectPollers } from "./project-pollers"
|
||||
import { createPollers, type ProjectPollers } from "./project/pollers"
|
||||
import { GitOps } from "./GitOps"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { BranchNamingController } from "./branch-naming"
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
type CreateWorktreeOnDiskOptions,
|
||||
type CreateWorktreeOnDiskResult,
|
||||
} from "./worktree-create"
|
||||
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project-init"
|
||||
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
|
||||
import { createLocalDiff } from "./local-diff"
|
||||
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
||||
import { sandboxSessionMetadata } from "../shared/sandbox-session"
|
||||
@@ -62,11 +62,12 @@ import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from
|
||||
import { ensureSandbox } from "./sandbox-bootstrap"
|
||||
import { Semaphore } from "./semaphore"
|
||||
import { PLATFORM } from "./constants"
|
||||
import { ProjectRegistry } from "./project-registry"
|
||||
import { ProjectContexts, type ProjectContext } from "./project-context"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "./project-messages"
|
||||
import { createProjectWiring } from "./project-wiring"
|
||||
import { ProjectScope } from "./project-scope"
|
||||
import { ProjectRegistry } from "./project/registry"
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import { ProjectContexts } from "./project/contexts"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages"
|
||||
import { createProjectWiring } from "./project/wiring"
|
||||
import { ProjectScope } from "./project/scope"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from "path"
|
||||
import { remoteRef, type Worktree } from "./WorktreeStateManager"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import { findTrackedBranch } from "./project-paths"
|
||||
import { findTrackedBranch } from "./project/paths"
|
||||
import type { WorktreeDiffEntry } from "./types"
|
||||
|
||||
export interface WorktreeStats {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import { normalizePath } from "./git-import"
|
||||
import type { SidebarTarget } from "./project-route"
|
||||
import type { SidebarTarget } from "./project/route"
|
||||
|
||||
/** Accept a persisted sidebar target only when its shape matches a known kind. */
|
||||
function validTarget(value: unknown): SidebarTarget | undefined {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./project/route"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
/**
|
||||
* ProjectContext — immutable repository scope for Agent Manager.
|
||||
* ProjectContexts — coordinator owning all contexts for one panel.
|
||||
*
|
||||
* A context owns the repository-bound services that used to be singletons on
|
||||
* the provider: WorktreeStateManager, WorktreeManager, SetupScriptService,
|
||||
* plus stale-worktree tracking. Services are created lazily on first access
|
||||
* and never re-pointed at another root, so a context can never mix state
|
||||
* between repositories.
|
||||
*
|
||||
* Lifecycle policy:
|
||||
* - The pinned context is derived from the current VS Code workspace root and
|
||||
* always exists while a folder is open. It is implicit and never persisted.
|
||||
* - Additional projects come from the ProjectRegistry. Their contexts are
|
||||
* created on demand (expand/select), never eagerly at panel open.
|
||||
* - Only the active context gets full Git/PR polling; the pollers follow the
|
||||
* active context through the provider's accessors.
|
||||
* - Non-pinned contexts require the multi-project flag and registry trust
|
||||
* before they can be expanded or activated. Trust is checked here, before
|
||||
* any state load that could write git metadata.
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { WorktreeManager } from "./WorktreeManager"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import { canonicalizePath, projectIdFor, samePath } from "./project-paths"
|
||||
import type { ProjectSessionView } from "./project-session-view"
|
||||
import type { StoredProject } from "./project-registry"
|
||||
|
||||
export interface ProjectContextDeps {
|
||||
log: (msg: string) => void
|
||||
git?: GitOps
|
||||
exists?: (dir: string) => boolean
|
||||
/** Factory overrides for tests. */
|
||||
state?: (root: string, log: (msg: string) => void) => WorktreeStateManager
|
||||
worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager
|
||||
setup?: (root: string) => SetupScriptService
|
||||
}
|
||||
|
||||
export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed"
|
||||
|
||||
export interface ProjectInitResult {
|
||||
ok: boolean
|
||||
refsFixed: number
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export class ProjectContext {
|
||||
private state: WorktreeStateManager | undefined
|
||||
private worktrees: WorktreeManager | undefined
|
||||
private setup: SetupScriptService | undefined
|
||||
private init: Promise<ProjectInitResult> | undefined
|
||||
private last: ProjectInitResult | undefined
|
||||
private phase: ProjectLifecycle = "cold"
|
||||
private version = 0
|
||||
private mutation: Promise<unknown> = Promise.resolve()
|
||||
private live = new Set<string>()
|
||||
private listed = 0
|
||||
private views: readonly ProjectSessionView[] = []
|
||||
readonly stale = new Set<string>()
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly root: string,
|
||||
readonly pinned: boolean,
|
||||
private readonly deps: ProjectContextDeps,
|
||||
) {}
|
||||
|
||||
get lifecycle(): ProjectLifecycle {
|
||||
return this.phase
|
||||
}
|
||||
|
||||
get generation(): number {
|
||||
return this.version
|
||||
}
|
||||
|
||||
isCurrent(generation: number): boolean {
|
||||
return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed"
|
||||
}
|
||||
|
||||
/** Initialize repository state exactly once per context lifetime. */
|
||||
ensureReady(run: (generation: number) => Promise<Omit<ProjectInitResult, "current">>): Promise<ProjectInitResult> {
|
||||
if (this.phase === "disposed" || this.phase === "disposing") {
|
||||
return Promise.resolve({ ok: false, refsFixed: 0, current: false })
|
||||
}
|
||||
if (this.phase === "ready" && this.last) return Promise.resolve(this.last)
|
||||
if (this.phase === "suspended" && this.last) {
|
||||
this.phase = "ready"
|
||||
return Promise.resolve(this.last)
|
||||
}
|
||||
if (this.init) return this.init
|
||||
const generation = this.version
|
||||
this.phase = "initializing"
|
||||
this.init = run(generation)
|
||||
.then((result) => {
|
||||
const current = this.isCurrent(generation)
|
||||
const next = { ...result, current }
|
||||
if (current) {
|
||||
this.last = next
|
||||
this.phase = result.ok ? "ready" : "cold"
|
||||
}
|
||||
return next
|
||||
})
|
||||
.finally(() => {
|
||||
this.init = undefined
|
||||
})
|
||||
return this.init
|
||||
}
|
||||
|
||||
/** Invalidate asynchronous work while keeping loaded repository state reusable. */
|
||||
suspend(): void {
|
||||
if (this.phase === "disposed" || this.phase === "disposing") return
|
||||
this.version++
|
||||
this.phase = "suspended"
|
||||
}
|
||||
|
||||
/** Serialize repository mutations and pin them to this context generation. */
|
||||
run<T>(operation: (generation: number) => Promise<T>): Promise<T> {
|
||||
const generation = this.version
|
||||
const run = async () => {
|
||||
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`)
|
||||
const result = await operation(generation)
|
||||
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`)
|
||||
return result
|
||||
}
|
||||
const next = this.mutation.then(run, run)
|
||||
this.mutation = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
stateManager(): WorktreeStateManager {
|
||||
this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) =>
|
||||
this.deps.log(`[StateManager] ${msg}`),
|
||||
)
|
||||
return this.state
|
||||
}
|
||||
|
||||
worktreeManager(): WorktreeManager {
|
||||
this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))(
|
||||
this.root,
|
||||
(msg) => this.deps.log(`[WorktreeManager] ${msg}`),
|
||||
this.deps.git,
|
||||
)
|
||||
return this.worktrees
|
||||
}
|
||||
|
||||
setupService(): SetupScriptService {
|
||||
this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root)
|
||||
return this.setup
|
||||
}
|
||||
|
||||
/** Whether repository-bound services have been created. */
|
||||
get loaded(): boolean {
|
||||
return this.state !== undefined
|
||||
}
|
||||
|
||||
/** Accessors that never create services, preserving "created?" checks. */
|
||||
peekState(): WorktreeStateManager | undefined {
|
||||
return this.state
|
||||
}
|
||||
|
||||
peekWorktrees(): WorktreeManager | undefined {
|
||||
return this.worktrees
|
||||
}
|
||||
|
||||
peekSetup(): SetupScriptService | undefined {
|
||||
return this.setup
|
||||
}
|
||||
|
||||
setLiveSessions(ids: Iterable<string>): void {
|
||||
this.live = new Set(ids)
|
||||
}
|
||||
|
||||
hasLiveSession(id: string): boolean {
|
||||
return this.live.has(id)
|
||||
}
|
||||
|
||||
/** Replace the cached sidebar session list and mark it freshly listed. */
|
||||
setSessions(views: readonly ProjectSessionView[]): void {
|
||||
this.views = views
|
||||
this.live = new Set(views.map((view) => view.id))
|
||||
this.listed = Date.now()
|
||||
}
|
||||
|
||||
/** The last collected sidebar session list, for re-posting on fresh skips. */
|
||||
sessions(): readonly ProjectSessionView[] {
|
||||
return this.views
|
||||
}
|
||||
|
||||
/** Force the next push to re-list from the backend. */
|
||||
invalidateSessions(): void {
|
||||
this.listed = 0
|
||||
}
|
||||
|
||||
/** Insert or refresh one session in the cached list (creation, rename, fork). */
|
||||
upsertSession(view: ProjectSessionView): void {
|
||||
this.views = [view, ...this.views.filter((item) => item.id !== view.id)]
|
||||
this.live.add(view.id)
|
||||
}
|
||||
|
||||
/** Drop one session from the cached list (deletion, close). */
|
||||
removeLiveSession(id: string): void {
|
||||
this.views = this.views.filter((item) => item.id !== id)
|
||||
this.live.delete(id)
|
||||
}
|
||||
|
||||
/** Mark that the backend session list was just collected for this context. */
|
||||
markSessionsListed(): void {
|
||||
this.listed = Date.now()
|
||||
}
|
||||
|
||||
/** Whether the last backend session listing is still fresh enough to reuse. */
|
||||
sessionsListedFresh(ms: number): boolean {
|
||||
return this.listed > 0 && Date.now() - this.listed < ms
|
||||
}
|
||||
|
||||
missing(): boolean {
|
||||
return !(this.deps.exists ?? fs.existsSync)(this.root)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.phase === "disposed") return
|
||||
this.version++
|
||||
this.phase = "disposing"
|
||||
await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`))
|
||||
await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`))
|
||||
await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`))
|
||||
this.live.clear()
|
||||
this.phase = "disposed"
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializable project description for the webview. */
|
||||
export interface ProjectSnapshot {
|
||||
id: string
|
||||
root: string
|
||||
label: string
|
||||
pinned: boolean
|
||||
active: boolean
|
||||
expanded: boolean
|
||||
initialized: boolean
|
||||
trusted: boolean
|
||||
missing: boolean
|
||||
}
|
||||
|
||||
interface ContextsOptions {
|
||||
/** Current VS Code workspace root; may change when workspace folders change. */
|
||||
workspaceRoot: () => string | undefined
|
||||
registry: {
|
||||
list(): StoredProject[]
|
||||
get(id: string): StoredProject | undefined
|
||||
}
|
||||
/** Registry trust lookup for non-pinned projects. */
|
||||
trusted: (id: string) => boolean
|
||||
/** Whether the multi-project experiment is enabled. */
|
||||
enabled: () => boolean
|
||||
remove?: (id: string) => void
|
||||
deps: ProjectContextDeps
|
||||
}
|
||||
|
||||
export class ProjectContexts {
|
||||
private readonly contexts = new Map<string, ProjectContext>()
|
||||
private activeId: string | undefined
|
||||
private readonly expanded = new Set<string>()
|
||||
|
||||
constructor(private readonly opts: ContextsOptions) {}
|
||||
|
||||
/** The pinned workspace project, derived lazily from the current workspace root. */
|
||||
pinned(): ProjectContext | undefined {
|
||||
const root = this.opts.workspaceRoot()
|
||||
if (!root) return undefined
|
||||
const canonical = canonicalizePath(root)
|
||||
return this.ensure(projectIdFor(canonical), canonical, true)
|
||||
}
|
||||
|
||||
private ensure(id: string, root: string, pinned: boolean): ProjectContext {
|
||||
let ctx = this.contexts.get(id)
|
||||
if (!ctx) {
|
||||
ctx = new ProjectContext(id, root, pinned, this.opts.deps)
|
||||
this.contexts.set(id, ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Resolve any known project id to a context, creating it on demand. */
|
||||
private resolveCtx(id: string): ProjectContext | undefined {
|
||||
const existing = this.contexts.get(id)
|
||||
if (existing) return existing
|
||||
const pinned = this.pinned()
|
||||
if (pinned?.id === id) return pinned
|
||||
if (!this.opts.enabled()) return undefined
|
||||
const stored = this.opts.registry.get(id)
|
||||
if (!stored) return undefined
|
||||
return this.ensure(stored.id, stored.root, false)
|
||||
}
|
||||
|
||||
/** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */
|
||||
active(): ProjectContext | undefined {
|
||||
if (this.activeId) return this.contexts.get(this.activeId)
|
||||
const pinned = this.pinned()
|
||||
if (pinned) {
|
||||
this.activeId = pinned.id
|
||||
this.expanded.add(pinned.id)
|
||||
return pinned
|
||||
}
|
||||
if (!this.opts.enabled()) return undefined
|
||||
const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id))
|
||||
if (!first) return undefined
|
||||
const ctx = this.ensure(first.id, first.root, false)
|
||||
this.activeId = ctx.id
|
||||
this.expanded.add(ctx.id)
|
||||
return ctx
|
||||
}
|
||||
|
||||
get(id: string): ProjectContext | undefined {
|
||||
return this.contexts.get(id)
|
||||
}
|
||||
|
||||
/** The context that owns a directory: its root or one of its worktree paths. */
|
||||
byDirectory(dir: string): ProjectContext | undefined {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (samePath(ctx.root, dir)) return ctx
|
||||
const state = ctx.peekState()
|
||||
if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The context whose live session list contains the session. */
|
||||
byLiveSession(id: string): ProjectContext | undefined {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (ctx.hasLiveSession(id)) return ctx
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve any known project id to a context without activating it. */
|
||||
resolve(id: string): ProjectContext | undefined {
|
||||
return this.resolveCtx(id)
|
||||
}
|
||||
|
||||
/** Whether a project may be shown or initialized: known, flag-gated, and trusted. */
|
||||
usable(id: string): ProjectContext | undefined {
|
||||
return this.usableCtx(id)
|
||||
}
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.active()?.id === id
|
||||
}
|
||||
|
||||
isExpanded(id: string): boolean {
|
||||
return this.expanded.has(id)
|
||||
}
|
||||
|
||||
/** Make a project the active context and expand it. Returns undefined when not allowed. */
|
||||
activate(id: string): ProjectContext | undefined {
|
||||
const ctx = this.usableCtx(id)
|
||||
if (!ctx) return undefined
|
||||
this.activeId = id
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Expand a project without activating it. Returns undefined when not allowed. */
|
||||
expand(id: string): ProjectContext | undefined {
|
||||
const ctx = this.usableCtx(id)
|
||||
if (!ctx) return undefined
|
||||
this.expanded.add(id)
|
||||
return ctx
|
||||
}
|
||||
|
||||
collapse(id: string): void {
|
||||
this.expanded.delete(id)
|
||||
if (this.isActive(id)) return
|
||||
this.contexts.get(id)?.suspend()
|
||||
}
|
||||
|
||||
/** Return ownership to pinned Local and suspend all secondary contexts. */
|
||||
disable(): ProjectContext | undefined {
|
||||
const pinned = this.pinned()
|
||||
this.activeId = pinned?.id
|
||||
if (pinned) this.expanded.add(pinned.id)
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (ctx.pinned) continue
|
||||
this.expanded.delete(ctx.id)
|
||||
ctx.suspend()
|
||||
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
|
||||
// route service accumulates entries for every disabled project.
|
||||
this.opts.remove?.(ctx.id)
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
private usableCtx(id: string): ProjectContext | undefined {
|
||||
const ctx = this.resolveCtx(id)
|
||||
if (!ctx) return undefined
|
||||
if (ctx.pinned) return ctx
|
||||
if (!this.opts.enabled()) return undefined
|
||||
if (!this.opts.trusted(id)) return undefined
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Remove a non-pinned project context. Falls back to the pinned project when it was active. */
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const ctx = this.contexts.get(id)
|
||||
if (!ctx || ctx.pinned) return false
|
||||
this.expanded.delete(id)
|
||||
if (this.activeId === id) this.activeId = undefined
|
||||
this.contexts.delete(id)
|
||||
this.opts.remove?.(id)
|
||||
await ctx.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the pinned project after workspace folder changes. Disposes the
|
||||
* old pinned context so cached services can never mix two roots. Returns
|
||||
* true when the active context may have changed.
|
||||
*/
|
||||
syncPinned(): boolean {
|
||||
const root = this.opts.workspaceRoot()
|
||||
const next = root ? projectIdFor(canonicalizePath(root)) : undefined
|
||||
const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id
|
||||
if (current === next) return false
|
||||
for (const [id, ctx] of [...this.contexts]) {
|
||||
if (!ctx.pinned) continue
|
||||
this.contexts.delete(id)
|
||||
this.expanded.delete(id)
|
||||
if (this.activeId === id) this.activeId = undefined
|
||||
this.opts.remove?.(id)
|
||||
ctx.suspend()
|
||||
void ctx.dispose()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serializable snapshots for the webview: pinned first, then registry order. */
|
||||
snapshots(): ProjectSnapshot[] {
|
||||
const out: ProjectSnapshot[] = []
|
||||
const pinned = this.pinned()
|
||||
if (pinned) out.push(this.snapshot(pinned, undefined))
|
||||
if (!this.opts.enabled()) return out
|
||||
for (const stored of this.opts.registry.list()) {
|
||||
if (pinned?.id === stored.id) continue
|
||||
out.push(this.snapshot(this.contexts.get(stored.id), stored))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot {
|
||||
const id = ctx?.id ?? stored!.id
|
||||
const root = ctx?.root ?? stored!.root
|
||||
const pinned = ctx?.pinned ?? false
|
||||
return {
|
||||
id,
|
||||
root,
|
||||
label: stored?.label || path.basename(root) || root,
|
||||
pinned,
|
||||
active: this.isActive(id),
|
||||
expanded: this.isExpanded(id),
|
||||
initialized: ctx?.loaded ?? false,
|
||||
trusted: pinned || (stored?.trusted ?? false),
|
||||
missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
this.opts.remove?.(ctx.id)
|
||||
await ctx.dispose()
|
||||
}
|
||||
this.contexts.clear()
|
||||
this.expanded.clear()
|
||||
this.activeId = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* ProjectContext — immutable repository scope for Agent Manager.
|
||||
*
|
||||
* A context owns the repository-bound services that used to be singletons on
|
||||
* the provider: WorktreeStateManager, WorktreeManager, SetupScriptService,
|
||||
* plus stale-worktree tracking. Services are created lazily on first access
|
||||
* and never re-pointed at another root, so a context can never mix state
|
||||
* between repositories.
|
||||
*
|
||||
* Lifecycle policy:
|
||||
* - The pinned context is derived from the current VS Code workspace root and
|
||||
* always exists while a folder is open. It is implicit and never persisted.
|
||||
* - Additional projects come from the ProjectRegistry. Their contexts are
|
||||
* created on demand (expand/select), never eagerly at panel open.
|
||||
* - Only the active context gets full Git/PR polling; the pollers follow the
|
||||
* active context through the provider's accessors.
|
||||
* - Non-pinned contexts require the multi-project flag and registry trust
|
||||
* before they can be expanded or activated. Trust is checked here, before
|
||||
* any state load that could write git metadata.
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import { WorktreeStateManager } from "../WorktreeStateManager"
|
||||
import { WorktreeManager } from "../WorktreeManager"
|
||||
import { SetupScriptService } from "../SetupScriptService"
|
||||
import type { GitOps } from "../GitOps"
|
||||
import type { ProjectSessionView } from "./session-view"
|
||||
|
||||
export interface ProjectContextDeps {
|
||||
log: (msg: string) => void
|
||||
git?: GitOps
|
||||
exists?: (dir: string) => boolean
|
||||
/** Factory overrides for tests. */
|
||||
state?: (root: string, log: (msg: string) => void) => WorktreeStateManager
|
||||
worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager
|
||||
setup?: (root: string) => SetupScriptService
|
||||
}
|
||||
|
||||
export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed"
|
||||
|
||||
export interface ProjectInitResult {
|
||||
ok: boolean
|
||||
refsFixed: number
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export class ProjectContext {
|
||||
private state: WorktreeStateManager | undefined
|
||||
private worktrees: WorktreeManager | undefined
|
||||
private setup: SetupScriptService | undefined
|
||||
private init: Promise<ProjectInitResult> | undefined
|
||||
private last: ProjectInitResult | undefined
|
||||
private phase: ProjectLifecycle = "cold"
|
||||
private version = 0
|
||||
private mutation: Promise<unknown> = Promise.resolve()
|
||||
private live = new Set<string>()
|
||||
private listed = 0
|
||||
private views: readonly ProjectSessionView[] = []
|
||||
readonly stale = new Set<string>()
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly root: string,
|
||||
readonly pinned: boolean,
|
||||
private readonly deps: ProjectContextDeps,
|
||||
) {}
|
||||
|
||||
get lifecycle(): ProjectLifecycle {
|
||||
return this.phase
|
||||
}
|
||||
|
||||
get generation(): number {
|
||||
return this.version
|
||||
}
|
||||
|
||||
isCurrent(generation: number): boolean {
|
||||
return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed"
|
||||
}
|
||||
|
||||
/** Initialize repository state exactly once per context lifetime. */
|
||||
ensureReady(run: (generation: number) => Promise<Omit<ProjectInitResult, "current">>): Promise<ProjectInitResult> {
|
||||
if (this.phase === "disposed" || this.phase === "disposing") {
|
||||
return Promise.resolve({ ok: false, refsFixed: 0, current: false })
|
||||
}
|
||||
if (this.phase === "ready" && this.last) return Promise.resolve(this.last)
|
||||
if (this.phase === "suspended" && this.last) {
|
||||
this.phase = "ready"
|
||||
return Promise.resolve(this.last)
|
||||
}
|
||||
if (this.init) return this.init
|
||||
const generation = this.version
|
||||
this.phase = "initializing"
|
||||
this.init = run(generation)
|
||||
.then((result) => {
|
||||
const current = this.isCurrent(generation)
|
||||
const next = { ...result, current }
|
||||
if (current) {
|
||||
this.last = next
|
||||
this.phase = result.ok ? "ready" : "cold"
|
||||
}
|
||||
return next
|
||||
})
|
||||
.finally(() => {
|
||||
this.init = undefined
|
||||
})
|
||||
return this.init
|
||||
}
|
||||
|
||||
/** Invalidate asynchronous work while keeping loaded repository state reusable. */
|
||||
suspend(): void {
|
||||
if (this.phase === "disposed" || this.phase === "disposing") return
|
||||
this.version++
|
||||
this.phase = "suspended"
|
||||
}
|
||||
|
||||
/** Serialize repository mutations and pin them to this context generation. */
|
||||
run<T>(operation: (generation: number) => Promise<T>): Promise<T> {
|
||||
const generation = this.version
|
||||
const run = async () => {
|
||||
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`)
|
||||
const result = await operation(generation)
|
||||
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`)
|
||||
return result
|
||||
}
|
||||
const next = this.mutation.then(run, run)
|
||||
this.mutation = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
stateManager(): WorktreeStateManager {
|
||||
this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) =>
|
||||
this.deps.log(`[StateManager] ${msg}`),
|
||||
)
|
||||
return this.state
|
||||
}
|
||||
|
||||
worktreeManager(): WorktreeManager {
|
||||
this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))(
|
||||
this.root,
|
||||
(msg) => this.deps.log(`[WorktreeManager] ${msg}`),
|
||||
this.deps.git,
|
||||
)
|
||||
return this.worktrees
|
||||
}
|
||||
|
||||
setupService(): SetupScriptService {
|
||||
this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root)
|
||||
return this.setup
|
||||
}
|
||||
|
||||
/** Whether repository-bound services have been created. */
|
||||
get loaded(): boolean {
|
||||
return this.state !== undefined
|
||||
}
|
||||
|
||||
/** Accessors that never create services, preserving "created?" checks. */
|
||||
peekState(): WorktreeStateManager | undefined {
|
||||
return this.state
|
||||
}
|
||||
|
||||
peekWorktrees(): WorktreeManager | undefined {
|
||||
return this.worktrees
|
||||
}
|
||||
|
||||
peekSetup(): SetupScriptService | undefined {
|
||||
return this.setup
|
||||
}
|
||||
|
||||
setLiveSessions(ids: Iterable<string>): void {
|
||||
this.live = new Set(ids)
|
||||
}
|
||||
|
||||
hasLiveSession(id: string): boolean {
|
||||
return this.live.has(id)
|
||||
}
|
||||
|
||||
/** Replace the cached sidebar session list and mark it freshly listed. */
|
||||
setSessions(views: readonly ProjectSessionView[]): void {
|
||||
this.views = views
|
||||
this.live = new Set(views.map((view) => view.id))
|
||||
this.listed = Date.now()
|
||||
}
|
||||
|
||||
/** The last collected sidebar session list, for re-posting on fresh skips. */
|
||||
sessions(): readonly ProjectSessionView[] {
|
||||
return this.views
|
||||
}
|
||||
|
||||
/** Force the next push to re-list from the backend. */
|
||||
invalidateSessions(): void {
|
||||
this.listed = 0
|
||||
}
|
||||
|
||||
/** Insert or refresh one session in the cached list (creation, rename, fork). */
|
||||
upsertSession(view: ProjectSessionView): void {
|
||||
this.views = [view, ...this.views.filter((item) => item.id !== view.id)]
|
||||
this.live.add(view.id)
|
||||
}
|
||||
|
||||
/** Drop one session from the cached list (deletion, close). */
|
||||
removeLiveSession(id: string): void {
|
||||
this.views = this.views.filter((item) => item.id !== id)
|
||||
this.live.delete(id)
|
||||
}
|
||||
|
||||
/** Mark that the backend session list was just collected for this context. */
|
||||
markSessionsListed(): void {
|
||||
this.listed = Date.now()
|
||||
}
|
||||
|
||||
/** Whether the last backend session listing is still fresh enough to reuse. */
|
||||
sessionsListedFresh(ms: number): boolean {
|
||||
return this.listed > 0 && Date.now() - this.listed < ms
|
||||
}
|
||||
|
||||
missing(): boolean {
|
||||
return !(this.deps.exists ?? fs.existsSync)(this.root)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.phase === "disposed") return
|
||||
this.version++
|
||||
this.phase = "disposing"
|
||||
await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`))
|
||||
await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`))
|
||||
await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`))
|
||||
this.live.clear()
|
||||
this.phase = "disposed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* ProjectContexts — coordinator owning all ProjectContext instances for one panel.
|
||||
*
|
||||
* Derives the pinned context from the current workspace root, creates registry
|
||||
* project contexts on demand (expand/select), tracks which project is active
|
||||
* and which are expanded, and serializes snapshots for the webview. See
|
||||
* ./context.ts for the per-repository lifecycle policy.
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { canonicalizePath, projectIdFor, samePath } from "./paths"
|
||||
import type { StoredProject } from "./registry"
|
||||
import { ProjectContext, type ProjectContextDeps } from "./context"
|
||||
|
||||
/** Serializable project description for the webview. */
|
||||
export interface ProjectSnapshot {
|
||||
id: string
|
||||
root: string
|
||||
label: string
|
||||
pinned: boolean
|
||||
active: boolean
|
||||
expanded: boolean
|
||||
initialized: boolean
|
||||
trusted: boolean
|
||||
missing: boolean
|
||||
}
|
||||
|
||||
interface ContextsOptions {
|
||||
/** Current VS Code workspace root; may change when workspace folders change. */
|
||||
workspaceRoot: () => string | undefined
|
||||
registry: {
|
||||
list(): StoredProject[]
|
||||
get(id: string): StoredProject | undefined
|
||||
}
|
||||
/** Registry trust lookup for non-pinned projects. */
|
||||
trusted: (id: string) => boolean
|
||||
/** Whether the multi-project experiment is enabled. */
|
||||
enabled: () => boolean
|
||||
remove?: (id: string) => void
|
||||
deps: ProjectContextDeps
|
||||
}
|
||||
|
||||
export class ProjectContexts {
|
||||
private readonly contexts = new Map<string, ProjectContext>()
|
||||
private activeId: string | undefined
|
||||
private readonly expanded = new Set<string>()
|
||||
|
||||
constructor(private readonly opts: ContextsOptions) {}
|
||||
|
||||
/** The pinned workspace project, derived lazily from the current workspace root. */
|
||||
pinned(): ProjectContext | undefined {
|
||||
const root = this.opts.workspaceRoot()
|
||||
if (!root) return undefined
|
||||
const canonical = canonicalizePath(root)
|
||||
return this.ensure(projectIdFor(canonical), canonical, true)
|
||||
}
|
||||
|
||||
private ensure(id: string, root: string, pinned: boolean): ProjectContext {
|
||||
let ctx = this.contexts.get(id)
|
||||
if (!ctx) {
|
||||
ctx = new ProjectContext(id, root, pinned, this.opts.deps)
|
||||
this.contexts.set(id, ctx)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Resolve any known project id to a context, creating it on demand. */
|
||||
private resolveCtx(id: string): ProjectContext | undefined {
|
||||
const existing = this.contexts.get(id)
|
||||
if (existing) return existing
|
||||
const pinned = this.pinned()
|
||||
if (pinned?.id === id) return pinned
|
||||
if (!this.opts.enabled()) return undefined
|
||||
const stored = this.opts.registry.get(id)
|
||||
if (!stored) return undefined
|
||||
return this.ensure(stored.id, stored.root, false)
|
||||
}
|
||||
|
||||
/** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */
|
||||
active(): ProjectContext | undefined {
|
||||
if (this.activeId) return this.contexts.get(this.activeId)
|
||||
const pinned = this.pinned()
|
||||
if (pinned) {
|
||||
this.activeId = pinned.id
|
||||
this.expanded.add(pinned.id)
|
||||
return pinned
|
||||
}
|
||||
if (!this.opts.enabled()) return undefined
|
||||
const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id))
|
||||
if (!first) return undefined
|
||||
const ctx = this.ensure(first.id, first.root, false)
|
||||
this.activeId = ctx.id
|
||||
this.expanded.add(ctx.id)
|
||||
return ctx
|
||||
}
|
||||
|
||||
get(id: string): ProjectContext | undefined {
|
||||
return this.contexts.get(id)
|
||||
}
|
||||
|
||||
/** The context that owns a directory: its root or one of its worktree paths. */
|
||||
byDirectory(dir: string): ProjectContext | undefined {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (samePath(ctx.root, dir)) return ctx
|
||||
const state = ctx.peekState()
|
||||
if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The context whose live session list contains the session. */
|
||||
byLiveSession(id: string): ProjectContext | undefined {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (ctx.hasLiveSession(id)) return ctx
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve any known project id to a context without activating it. */
|
||||
resolve(id: string): ProjectContext | undefined {
|
||||
return this.resolveCtx(id)
|
||||
}
|
||||
|
||||
/** Whether a project may be shown or initialized: known, flag-gated, and trusted. */
|
||||
usable(id: string): ProjectContext | undefined {
|
||||
return this.usableCtx(id)
|
||||
}
|
||||
|
||||
isActive(id: string): boolean {
|
||||
return this.active()?.id === id
|
||||
}
|
||||
|
||||
isExpanded(id: string): boolean {
|
||||
return this.expanded.has(id)
|
||||
}
|
||||
|
||||
/** Make a project the active context and expand it. Returns undefined when not allowed. */
|
||||
activate(id: string): ProjectContext | undefined {
|
||||
const ctx = this.usableCtx(id)
|
||||
if (!ctx) return undefined
|
||||
this.activeId = id
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Expand a project without activating it. Returns undefined when not allowed. */
|
||||
expand(id: string): ProjectContext | undefined {
|
||||
const ctx = this.usableCtx(id)
|
||||
if (!ctx) return undefined
|
||||
this.expanded.add(id)
|
||||
return ctx
|
||||
}
|
||||
|
||||
collapse(id: string): void {
|
||||
this.expanded.delete(id)
|
||||
if (this.isActive(id)) return
|
||||
this.contexts.get(id)?.suspend()
|
||||
}
|
||||
|
||||
/** Return ownership to pinned Local and suspend all secondary contexts. */
|
||||
disable(): ProjectContext | undefined {
|
||||
const pinned = this.pinned()
|
||||
this.activeId = pinned?.id
|
||||
if (pinned) this.expanded.add(pinned.id)
|
||||
for (const ctx of this.contexts.values()) {
|
||||
if (ctx.pinned) continue
|
||||
this.expanded.delete(ctx.id)
|
||||
ctx.suspend()
|
||||
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
|
||||
// route service accumulates entries for every disabled project.
|
||||
this.opts.remove?.(ctx.id)
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
private usableCtx(id: string): ProjectContext | undefined {
|
||||
const ctx = this.resolveCtx(id)
|
||||
if (!ctx) return undefined
|
||||
if (ctx.pinned) return ctx
|
||||
if (!this.opts.enabled()) return undefined
|
||||
if (!this.opts.trusted(id)) return undefined
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Remove a non-pinned project context. Falls back to the pinned project when it was active. */
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const ctx = this.contexts.get(id)
|
||||
if (!ctx || ctx.pinned) return false
|
||||
this.expanded.delete(id)
|
||||
if (this.activeId === id) this.activeId = undefined
|
||||
this.contexts.delete(id)
|
||||
this.opts.remove?.(id)
|
||||
await ctx.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the pinned project after workspace folder changes. Disposes the
|
||||
* old pinned context so cached services can never mix two roots. Returns
|
||||
* true when the active context may have changed.
|
||||
*/
|
||||
syncPinned(): boolean {
|
||||
const root = this.opts.workspaceRoot()
|
||||
const next = root ? projectIdFor(canonicalizePath(root)) : undefined
|
||||
const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id
|
||||
if (current === next) return false
|
||||
for (const [id, ctx] of [...this.contexts]) {
|
||||
if (!ctx.pinned) continue
|
||||
this.contexts.delete(id)
|
||||
this.expanded.delete(id)
|
||||
if (this.activeId === id) this.activeId = undefined
|
||||
this.opts.remove?.(id)
|
||||
ctx.suspend()
|
||||
void ctx.dispose()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serializable snapshots for the webview: pinned first, then registry order. */
|
||||
snapshots(): ProjectSnapshot[] {
|
||||
const out: ProjectSnapshot[] = []
|
||||
const pinned = this.pinned()
|
||||
if (pinned) out.push(this.snapshot(pinned, undefined))
|
||||
if (!this.opts.enabled()) return out
|
||||
for (const stored of this.opts.registry.list()) {
|
||||
if (pinned?.id === stored.id) continue
|
||||
out.push(this.snapshot(this.contexts.get(stored.id), stored))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot {
|
||||
const id = ctx?.id ?? stored!.id
|
||||
const root = ctx?.root ?? stored!.root
|
||||
const pinned = ctx?.pinned ?? false
|
||||
return {
|
||||
id,
|
||||
root,
|
||||
label: stored?.label || path.basename(root) || root,
|
||||
pinned,
|
||||
active: this.isActive(id),
|
||||
expanded: this.isExpanded(id),
|
||||
initialized: ctx?.loaded ?? false,
|
||||
trusted: pinned || (stored?.trusted ?? false),
|
||||
missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const ctx of this.contexts.values()) {
|
||||
this.opts.remove?.(ctx.id)
|
||||
await ctx.dispose()
|
||||
}
|
||||
this.contexts.clear()
|
||||
this.expanded.clear()
|
||||
this.activeId = undefined
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -7,14 +7,14 @@
|
||||
* land before the persisted state file is read.
|
||||
*/
|
||||
|
||||
import { restoreWorktrees } from "./state-recovery"
|
||||
import type { ProjectContext, ProjectInitResult } from "./project-context"
|
||||
import { restoreWorktrees } from "../state-recovery"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
|
||||
import { sessionToWebview } from "../kilo-provider-utils"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./route"
|
||||
import { sessionToWebview } from "../../kilo-provider-utils"
|
||||
|
||||
export type { ProjectSessionView } from "./project-session-view"
|
||||
import type { ProjectSessionView } from "./project-session-view"
|
||||
export type { ProjectSessionView } from "./session-view"
|
||||
import type { ProjectSessionView } from "./session-view"
|
||||
|
||||
/**
|
||||
* Subset of {@link SessionProvider} needed to discover and register live
|
||||
+6
-5
@@ -7,11 +7,12 @@
|
||||
*/
|
||||
|
||||
import simpleGit from "simple-git"
|
||||
import type { AgentManagerInMessage } from "./types"
|
||||
import type { ProjectRegistry } from "./project-registry"
|
||||
import type { ProjectContext, ProjectContexts, ProjectInitResult } from "./project-context"
|
||||
import { projectIdFor, resolveProjectRoot, samePath } from "./project-paths"
|
||||
import type { SidebarTarget } from "./project-route"
|
||||
import type { AgentManagerInMessage } from "../types"
|
||||
import type { ProjectRegistry } from "./registry"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
import type { ProjectContexts } from "./contexts"
|
||||
import { projectIdFor, resolveProjectRoot, samePath } from "./paths"
|
||||
import type { SidebarTarget } from "./route"
|
||||
|
||||
export interface ProjectMessageDeps {
|
||||
registry: ProjectRegistry
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { createHash } from "crypto"
|
||||
import { normalizePath } from "./git-import"
|
||||
import { normalizePath } from "../git-import"
|
||||
|
||||
/** Resolve symlinks and normalize separators. Falls back to lexical resolution when the path does not exist. */
|
||||
export function canonicalizePath(dir: string): string {
|
||||
+10
-9
@@ -11,15 +11,16 @@
|
||||
* injectable.
|
||||
*/
|
||||
|
||||
import type { GitOps } from "./GitOps"
|
||||
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
|
||||
import { diffSummary } from "./local-diff"
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import type { PRStatus } from "./types"
|
||||
import type { ProjectContext, ProjectContexts } from "./project-context"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { GitOps } from "../GitOps"
|
||||
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "../GitStatsPoller"
|
||||
import { diffSummary } from "../local-diff"
|
||||
import { PRStatusBridge } from "../pr-status-bridge"
|
||||
import type { PRStatus } from "../types"
|
||||
import type { ProjectContext } from "./context"
|
||||
import type { ProjectContexts } from "./contexts"
|
||||
import type { Semaphore } from "../semaphore"
|
||||
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "../types"
|
||||
import type { WorktreeStateManager } from "../WorktreeStateManager"
|
||||
|
||||
export interface PollerPair {
|
||||
stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { AsyncLocalStorage } from "async_hooks"
|
||||
import type { ProjectContext } from "./project-context"
|
||||
import type { ProjectContext } from "./context"
|
||||
|
||||
/** Pins dynamic provider getters to the project that started an async operation. */
|
||||
export class ProjectScope {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { sessionToWebview } from "../kilo-provider-utils"
|
||||
import type { sessionToWebview } from "../../kilo-provider-utils"
|
||||
|
||||
/** A root session as shown in a project's sidebar, tagged with its worktree. */
|
||||
export type ProjectSessionView = ReturnType<typeof sessionToWebview> & { worktreeId: string | null }
|
||||
+7
-6
@@ -6,11 +6,12 @@
|
||||
* interface abstracts all platform capabilities.
|
||||
*/
|
||||
|
||||
import type { Host, Disposable } from "./host"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import { ProjectRegistry } from "./project-registry"
|
||||
import { ProjectContexts, type ProjectContext, type ProjectInitResult, type ProjectSnapshot } from "./project-context"
|
||||
import type { ProjectMessageDeps } from "./project-messages"
|
||||
import type { Host, Disposable } from "../host"
|
||||
import type { GitOps } from "../GitOps"
|
||||
import { ProjectRegistry } from "./registry"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
import { ProjectContexts, type ProjectSnapshot } from "./contexts"
|
||||
import type { ProjectMessageDeps } from "./messages"
|
||||
|
||||
export interface ProjectWiring {
|
||||
registry: ProjectRegistry
|
||||
@@ -39,7 +40,7 @@ export function createProjectWiring(opts: {
|
||||
/** Re-push worktree state (e.g. after the flag toggles). */
|
||||
refresh: () => void
|
||||
/** Acknowledge an atomically validated sidebar selection. */
|
||||
selected: (target: import("./project-route").SidebarTarget) => void
|
||||
selected: (target: import("./route").SidebarTarget) => void
|
||||
}): ProjectWiring {
|
||||
const registry = new ProjectRegistry(
|
||||
{ read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) },
|
||||
@@ -2,8 +2,8 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
|
||||
import { getErrorMessage } from "../kilo-provider-utils"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
import { PLATFORM } from "./constants"
|
||||
import { initContextState } from "./project-init"
|
||||
import type { ProjectContext } from "./project-context"
|
||||
import { initContextState } from "./project/init"
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import type { ManagedSession } from "./WorktreeStateManager"
|
||||
import type { CreateWorktreeResult, WorktreeManager } from "./WorktreeManager"
|
||||
import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create"
|
||||
|
||||
@@ -15,8 +15,8 @@ import type { ApplyConflict } from "./GitOps"
|
||||
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
|
||||
import type { RunStatus } from "./run/manager"
|
||||
import type { TerminalFont } from "./terminal-font"
|
||||
import type { ProjectSnapshot } from "./project-context"
|
||||
import type { SidebarTarget } from "./project-route"
|
||||
import type { ProjectSnapshot } from "./project/contexts"
|
||||
import type { SidebarTarget } from "./project/route"
|
||||
import type { TerminalDestination } from "./terminal-destination"
|
||||
|
||||
export type { TerminalFont }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host"
|
||||
import { ProjectRouteService } from "./project-route"
|
||||
import { ProjectRouteService } from "./project/route"
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
import { KiloProvider } from "../KiloProvider"
|
||||
import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as path from "path"
|
||||
import type * as vscode from "vscode"
|
||||
import simpleGit from "simple-git"
|
||||
import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project-paths"
|
||||
import { ProjectRegistry } from "./agent-manager/project-registry"
|
||||
import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project/paths"
|
||||
import { ProjectRegistry } from "./agent-manager/project/registry"
|
||||
|
||||
const KEY = "kilo.indexingConsent.v1"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProjectRouteService } from "../agent-manager/project-route"
|
||||
import type { ProjectRouteService } from "../agent-manager/project/route"
|
||||
|
||||
export type KiloProviderOptions = {
|
||||
/** Context key updated from focus events reported by this provider's webview. */
|
||||
|
||||
@@ -226,7 +226,7 @@ describe("Agent Manager Provider Messages", () => {
|
||||
})
|
||||
|
||||
it("state-mutating messages wait for state initialization", () => {
|
||||
const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/state-gate.ts"), "utf-8")
|
||||
const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/state-gate.ts"), "utf-8")
|
||||
const messages = [
|
||||
"agentManager.setTabOrder",
|
||||
"agentManager.setWorktreeOrder",
|
||||
@@ -246,7 +246,7 @@ describe("Agent Manager Provider Messages", () => {
|
||||
})
|
||||
|
||||
it("context state init updates local git exclude before loading persisted state", () => {
|
||||
const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project-init.ts"), "utf-8")
|
||||
const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/init.ts"), "utf-8")
|
||||
const exclude = text.indexOf("ensureGitExclude(")
|
||||
const load = text.indexOf("state.load()")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project-context"
|
||||
import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project-registry"
|
||||
import { projectIdFor } from "../../src/agent-manager/project-paths"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project/registry"
|
||||
import { projectIdFor } from "../../src/agent-manager/project/paths"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
const WORKSPACE = "/repo/main"
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { execFileSync } from "child_process"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
|
||||
import { ProjectRegistry } from "../../src/agent-manager/project-registry"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project-context"
|
||||
import { projectIdFor } from "../../src/agent-manager/project-paths"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
|
||||
import { ProjectRegistry } from "../../src/agent-manager/project/registry"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import { projectIdFor } from "../../src/agent-manager/project/paths"
|
||||
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
|
||||
|
||||
const WORKSPACE = "/repo/main"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
findTrackedBranch,
|
||||
resolveProjectRoot,
|
||||
samePath,
|
||||
} from "../../src/agent-manager/project-paths"
|
||||
} from "../../src/agent-manager/project/paths"
|
||||
|
||||
describe("project-paths", () => {
|
||||
it("canonicalizePath resolves a symlink alias to its realpath", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project-pollers"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project-context"
|
||||
import type { StoredProject } from "../../src/agent-manager/project-registry"
|
||||
import { projectIdFor } from "../../src/agent-manager/project-paths"
|
||||
import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project/pollers"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import type { StoredProject } from "../../src/agent-manager/project/registry"
|
||||
import { projectIdFor } from "../../src/agent-manager/project/paths"
|
||||
import type { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
|
||||
import { reactivateProject } from "../../src/agent-manager/project-init"
|
||||
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
|
||||
import { reactivateProject } from "../../src/agent-manager/project/init"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
const ROOT = "/repo/main"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project-route"
|
||||
import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project/route"
|
||||
|
||||
describe("ProjectRouteService", () => {
|
||||
it("resolves explicit local, worktree, and session routes", () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
|
||||
import { ProjectContexts, type ProjectContext, type ProjectInitResult } from "../../src/agent-manager/project-context"
|
||||
import { projectIdFor } from "../../src/agent-manager/project-paths"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
|
||||
import type { ProjectContext, ProjectInitResult } from "../../src/agent-manager/project/context"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import { projectIdFor } from "../../src/agent-manager/project/paths"
|
||||
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
|
||||
import type { StoredProject } from "../../src/agent-manager/project-registry"
|
||||
import type { StoredProject } from "../../src/agent-manager/project/registry"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
const WORKSPACE = "/repo/main"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
|
||||
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
|
||||
import type { Worktree, ManagedSession, WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import {
|
||||
collectProjectSessions,
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
registerProjectSessions,
|
||||
unregisterProjectRoutes,
|
||||
type ProjectSessionListing,
|
||||
} from "../../src/agent-manager/project-init"
|
||||
import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project-route"
|
||||
} from "../../src/agent-manager/project/init"
|
||||
import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project/route"
|
||||
|
||||
const ROOT = "/repo/main"
|
||||
const WT_PATH = "/repo/main/.kilo/worktrees/fix"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { ProjectRouteService } from "../../src/agent-manager/project-route"
|
||||
import { ProjectRouteService } from "../../src/agent-manager/project/route"
|
||||
|
||||
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
|
||||
const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
|
||||
Reference in New Issue
Block a user