Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion src/__tests__/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vi.mock("vscode", () => ({
tabGroups: {
onDidChangeTabs: vi.fn(),
},
onDidChangeActiveTextEditor: vi.fn(),
onDidChangeActiveTextEditor: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspace: {
registerTextDocumentContentProvider: vi.fn(),
Expand Down Expand Up @@ -205,6 +205,7 @@ vi.mock("../core/webview/ClineProvider", async () => {
{
// Static method used by extension.ts
getVisibleInstance: vi.fn().mockReturnValue(mockInstance),
getAllInstances: vi.fn().mockReturnValue([]),
sideBarId: "zoo-code.SidebarProvider",
},
),
Expand Down Expand Up @@ -238,6 +239,141 @@ describe("extension.ts", () => {
settingsUpdatedHandler = undefined
})

test("initializes the code index scope and registers it for extension cleanup", async () => {
vi.resetModules()
const { CodeIndexScope } = await import("../services/code-index/code-index-scope")
const init = vi.spyOn(CodeIndexScope.prototype, "init")
const dispose = vi.spyOn(CodeIndexScope.prototype, "dispose")
try {
const { activate } = await import("../extension")
await activate(mockContext)

const scopes = mockContext.subscriptions.filter((entry) => entry instanceof CodeIndexScope)
expect(scopes).toHaveLength(1)
expect(init).toHaveBeenCalledExactlyOnceWith()
expect(init.mock.contexts[0]).toBe(scopes[0])
expect(dispose).not.toHaveBeenCalled()
scopes[0].dispose()
expect(dispose).toHaveBeenCalledExactlyOnceWith()
} finally {
init.mockRestore()
dispose.mockRestore()
}
})

test("publishes indexing status to matching providers and logs delivery failures", async () => {
vi.resetModules()
const { CodeIndexScope } = await import("../services/code-index/code-index-scope")
const { ClineProvider } = await import("../core/webview/ClineProvider")
const log = vi.spyOn(console, "error").mockImplementation(() => {})
const provider = ClineProvider.getVisibleInstance()!
Object.defineProperty(provider, "workspacePath", { configurable: true, value: "/workspace" })
const getAll = vi.mocked(ClineProvider.getAllInstances)
const post = vi.mocked(provider.postMessageToWebview)
getAll.mockReturnValue([provider, provider])
post.mockRejectedValueOnce(new Error("delivery failed")).mockResolvedValue(undefined)
try {
const { activate } = await import("../extension")
await activate(mockContext)
const scope = mockContext.subscriptions.find((entry) => entry instanceof CodeIndexScope)!
const status = {
systemStatus: "Standby" as const,
message: "Ready",
processedItems: 0,
totalItems: 0,
currentItemUnit: "blocks",
workspacePath: "/workspace",
workspaceEnabled: true,
autoEnableDefault: true,
}
scope["statusManager"]!["publishStatus"](status)
await Promise.resolve()
expect(post).toHaveBeenCalledTimes(2)
expect(post).toHaveBeenCalledWith({ type: "indexingStatusUpdate", values: status })
expect(log).toHaveBeenCalledWith(
"[CodeIndexStatusManager] Failed to publish indexing status:",
expect.objectContaining({ message: "delivery failed" }),
)
scope.dispose()
} finally {
log.mockRestore()
getAll.mockReturnValue([])
post.mockReset()
Reflect.deleteProperty(provider, "workspacePath")
}
})

test.each([
{ workspacePath: "/other-workspace", receivesStatus: false },
{ workspacePath: "/workspace", receivesStatus: true },
{ workspacePath: undefined, receivesStatus: true },
{ workspacePath: "", receivesStatus: true },
])(
"routes status for provider workspace=$workspacePath with receivesStatus=$receivesStatus",
async ({ workspacePath, receivesStatus }) => {
vi.resetModules()
const { CodeIndexScope } = await import("../services/code-index/code-index-scope")
const { ClineProvider } = await import("../core/webview/ClineProvider")
const provider = ClineProvider.getVisibleInstance()!
Object.defineProperty(provider, "workspacePath", { configurable: true, value: workspacePath })
const getAll = vi.mocked(ClineProvider.getAllInstances)
getAll.mockReturnValue([provider])
try {
const { activate } = await import("../extension")
await activate(mockContext)
const scope = mockContext.subscriptions.find((entry) => entry instanceof CodeIndexScope)!
const status = {
systemStatus: "Indexing" as const,
message: "Processing confidential.ts",
processedItems: 1,
totalItems: 2,
currentItemUnit: "files",
workspacePath: "/workspace",
workspaceEnabled: true,
autoEnableDefault: true,
}
scope["statusManager"]!["publishStatus"](status)
await Promise.resolve()
if (receivesStatus) {
expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({
type: "indexingStatusUpdate",
values: status,
})
} else {
expect(provider.postMessageToWebview).not.toHaveBeenCalled()
}
scope.dispose()
} finally {
getAll.mockReturnValue([])
Reflect.deleteProperty(provider, "workspacePath")
}
},
)

test.each([new Error("scope initialization failed"), "scope initialization failed"])(
"continues activation and logs code index scope initialization failure: %s",
async (error) => {
vi.resetModules()
const { CodeIndexScope } = await import("../services/code-index/code-index-scope")
const init = vi.spyOn(CodeIndexScope.prototype, "init").mockImplementationOnce(() => {
throw error
})
try {
const { activate } = await import("../extension")
await expect(activate(mockContext)).resolves.toBeDefined()

const vscode = await import("vscode")
const channel = vi.mocked(vscode.window.createOutputChannel).mock.results.at(-1)?.value
expect(channel?.appendLine).toHaveBeenCalledWith(
"[CodeIndexScope] Failed to initialize: scope initialization failed",
)
expect(mockContext.subscriptions.filter((entry) => entry instanceof CodeIndexScope)).toHaveLength(1)
} finally {
init.mockRestore()
}
},
)

test("does not call dotenv.config when optional .env does not exist", async () => {
vi.resetModules()
vi.clearAllMocks()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ToolCallbacks } from "../BaseTool"
import { CodebaseSearchTool } from "../CodebaseSearchTool"
import { CodeIndexManagerRegistry } from "../../../services/code-index/code-index-manager-registry"
import { CodeIndexManager } from "../../../services/code-index/manager"
import { CodeIndexStateManager } from "../../../services/code-index/state-manager"
import { getWorkspacePath } from "../../../utils/path"
import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode"

Expand All @@ -15,6 +16,7 @@ vi.mock("vscode", () => ({
Uri: { file: vi.fn() },
}))
vi.mock("../../../utils/path", () => ({ getWorkspacePath: vi.fn() }))
vi.mock("../../../services/code-index/state-manager")
vi.mock("../../../services/code-index/manager", () => ({
CodeIndexManager: vi.fn().mockImplementation(function (workspacePath: string) {
let initialized = false
Expand Down Expand Up @@ -193,6 +195,7 @@ describe("CodebaseSearchTool workspace selection", () => {
"/external-task",
expect.objectContaining({ fsPath: "/external-task" }),
provider.context,
expect.any(CodeIndexStateManager),
)
expect(vscode.Uri.file).toHaveBeenCalledExactlyOnceWith("/external-task")
const manager = CodeIndexManagerRegistry.getOrCreate(provider.context, "/external-task")!
Expand Down
68 changes: 5 additions & 63 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ import { MarketplaceManager } from "../../services/marketplace"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import type { CodeIndexManager } from "../../services/code-index/manager"
import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry"
import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
import { MdmService } from "../../services/mdm/MdmService"
import { SkillsManager } from "../../services/skills/SkillsManager"

Expand Down Expand Up @@ -212,8 +211,6 @@ export class ClineProvider
private taskScheduler = new TaskScheduler()
private static readonly delegationTransitionLocks = new Map<string, Promise<void>>()
private cancelledDelegationChildIds = new Set<string>()
private codeIndexStatusSubscription?: vscode.Disposable
private codeIndexManager?: CodeIndexManager
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
protected mcpHub?: McpHub // Change from private to protected
protected skillsManager?: SkillsManager
Expand Down Expand Up @@ -1082,17 +1079,6 @@ export class ClineProvider
// and executes code based on the message that is received.
this.setWebviewMessageListener(webviewView.webview)

// Initialize code index status subscription for the current workspace.
this.updateCodeIndexStatusSubscription()

// Listen for active editor changes to update code index status for the
// current workspace.
const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => {
// Update subscription when workspace might have changed.
this.updateCodeIndexStatusSubscription()
})
this.webviewDisposables.push(activeEditorSubscription)

// Listen for when the panel becomes visible.
// https://github.com/microsoft/vscode-discussions/discussions/840
if ("onDidChangeViewState" in webviewView) {
Expand Down Expand Up @@ -1130,8 +1116,6 @@ export class ClineProvider
} else {
this.log("Clearing webview resources for sidebar view")
this.clearWebviewResources()
// Reset current workspace manager reference when view is disposed
this.codeIndexManager = undefined
}
},
null,
Expand Down Expand Up @@ -3311,53 +3295,6 @@ export class ClineProvider
return CodeIndexManagerRegistry.getOrCreate(this.context)
}

/**
* Updates the code index status subscription to listen to the current workspace manager
*/
private updateCodeIndexStatusSubscription(): void {
// Get the current workspace manager
const currentManager = this.getCurrentWorkspaceCodeIndexManager()

// If the manager hasn't changed, no need to update subscription
if (currentManager === this.codeIndexManager) {
return
}

// Dispose the old subscription if it exists
if (this.codeIndexStatusSubscription) {
this.codeIndexStatusSubscription.dispose()
this.codeIndexStatusSubscription = undefined
}

// Update the current workspace manager reference
this.codeIndexManager = currentManager

// Subscribe to the new manager's progress updates if it exists
if (currentManager) {
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
// Only send updates if this manager is still the current one
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
// Get the full status from the manager to ensure we have all fields correctly formatted
const fullStatus = currentManager.getCurrentStatus()
void this.postMessageToWebview({
type: "indexingStatusUpdate",
values: fullStatus,
})
}
})

if (this.view) {
this.webviewDisposables.push(this.codeIndexStatusSubscription)
}

// Send initial status for the current workspace
void this.postMessageToWebview({
type: "indexingStatusUpdate",
values: currentManager.getCurrentStatus(),
})
}
}

/**
* TaskProviderLike, TelemetryPropertiesProvider
*/
Expand Down Expand Up @@ -3845,6 +3782,11 @@ export class ClineProvider
}
}

/** Workspace explicitly associated with this provider, without an active-editor fallback. */
public get workspacePath(): string | undefined {
return this.currentWorkspacePath
}

public get cwd() {
return this.currentWorkspacePath || getWorkspacePath()
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,15 @@ describe("ClineProvider", () => {
})
})

test("exposes only the explicitly associated workspace without a fallback", () => {
provider["currentWorkspacePath"] = "/workspace-a"
expect(provider.workspacePath).toBe("/workspace-a")
provider["currentWorkspacePath"] = "/workspace-b"
expect(provider.workspacePath).toBe("/workspace-b")
provider["currentWorkspacePath"] = undefined
expect(provider.workspacePath).toBeUndefined()
})

test("constructor initializes correctly", () => {
expect(provider).toBeInstanceOf(ClineProvider)
// Since getVisibleInstance returns the last instance where view.visible is true
Expand Down
10 changes: 10 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth"
import { McpServerManager } from "./services/mcp/McpServerManager"
import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry"
import { CodeIndexScope } from "./services/code-index/code-index-scope"
import { MdmService } from "./services/mdm/MdmService"
import { migrateSettings } from "./utils/migrateSettings"
import { autoImportSettings } from "./utils/autoImportSettings"
Expand Down Expand Up @@ -195,6 +196,15 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)

const codeIndexScope = new CodeIndexScope(context)
context.subscriptions.push(codeIndexScope)
try {
codeIndexScope.init()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
outputChannel.appendLine(`[CodeIndexScope] Failed to initialize: ${message}`)
}

// Initialize code index managers for all workspace folders.
if (vscode.workspace.workspaceFolders) {
for (const folder of vscode.workspace.workspaceFolders) {
Expand Down
Loading
Loading