Compare commits

...
Author SHA1 Message Date
abeatrix c1651c5233 revert refactor 2025-08-11 20:01:47 -07:00
abeatrix 028fe70019 fix imports 2025-08-11 18:08:02 -07:00
abeatrix 41c5e809bd Improve type safety and error handling for ClineHandler
- Add explicit TypeScript types for OpenRouter interfaces
- Reorganize imports with proper type annotations
- Remove unused counter property and improve method naming
- Improve error handling and code readability
2025-08-11 15:01:20 -07:00
Sarah Fortune 2aa5156905 Update ExternalDiffViewProvider to return diagnostics before/after diff (#5500)
The diff view is supposed to return any new errors or warnings after the
file is edited. The ExternalDiffViewProvider was just returning *all*
the errors.

When the DiffViewProvider is being reset, reset *all* the properties.

Add unit tests for diagnostics functionality

Refactoring:
- Move diagnostics into the parent DiffViewProvider, remove duplicate implementations in VscodeDiffViewProvider and ExternalDiffViewProvider
- Move duplicated code for converting FileDiagnostics to string to `diagnosticsToProblemsString`.
- Use a single implementation of `getDiagnostics` and `diagnosticsToProblemsString` using the HostBridge protos.
2025-08-11 19:54:41 +01:00
celestial-vault 51b619e0d5 remove workspace tracker (#5346)
* remove workspace tracker

* remove console log

* fix search when clicking folder option

* create enum for searchType

* use hostbridge for active files

* use util function for relative path

* Fix into interests error where false security warning is being triggered
2025-08-11 09:51:47 -07:00
Sarah Fortune 85fb76a996 Call teardown() when cline-core is stopped. (#5496) 2025-08-11 17:29:27 +01:00
Dennise Bartlett d73a7cfd06 Fix package-lock version and update CODEOWNERS (#5490) 2025-08-11 01:43:08 -07:00
Ara 489dfbc932 Fixing Read of Workspace root by index.ts (#5482)
* Fixing Read of Workspace root by index.ts

* Fixing Read of Workspace root by index.ts
2025-08-09 20:50:50 -07:00
Bee 314c416788 remove PostHog exception autocapture (#5481)
Remove enableExceptionAutocapture option from PostHog client configuration.
2025-08-09 16:49:07 -07:00
Igor Tceglevskii 3b19c2ec95 Click from a file name in chat to editor (#5422) 2025-08-09 15:48:12 -07:00
Sarah Fortune e04cbea504 Add an endpoint to the HostBridge for integration testing (#5476)
Add an RPC to the HostBridge that returns the contents of the
webview, for use in integration tests.
2025-08-09 23:14:29 +01:00
Toshii affac119f5 stop double counting tokens (#5426)
* updated

* cleanup
2025-08-09 13:05:36 -07:00
Sarah Fortune 3847a2545c Test is still flaking, increase the timeout (#5473) 2025-08-09 16:25:02 +01:00
Sarah Fortune 15593bac2a Use the active webview in ProtoBus getWebviewHtml. (#5444) 2025-08-09 16:24:46 +01:00
Sarah Fortune 84267efb9e Don't use activate() in cline-core (#5448)
* Don't use activate() in cline-core

Have separate code paths to set up the extension and cline-core.

This means the cline-core is not running all the vscode setup and is
only using one `Controller` (the one from the WebviewProvider).

Move the shared logic into common.ts.

* Comments and logging
2025-08-09 14:49:23 +01:00
41 changed files with 1242 additions and 689 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding safety guard for workspace root
+3 -1
View File
@@ -1 +1,3 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
/docs/
/.github/ @saoudrizwan @dcbartlett
/README.md @saoudrizwan @nickbaumann98
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.21.0",
"version": "3.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.21.0",
"version": "3.23.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+2 -2
View File
@@ -362,8 +362,8 @@
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e": "playwright install && vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"test:e2e:optimal": "vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"test:e2e": "playwright install && vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"test:e2e:optimal": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
+5
View File
@@ -55,6 +55,11 @@ message Boolean {
bool value = 1;
}
// the same as Boolean, but avoiding name conflicts
message BooleanResponse {
bool value = 1;
}
message StringArray {
repeated string values = 1;
}
+12 -2
View File
@@ -55,8 +55,11 @@ service FileService {
// Toggles a workflow on or off
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
// Subscribe to workspace file updates
rpc subscribeToWorkspaceUpdates(EmptyRequest) returns (stream StringArray);
// Check if file exists in the project
rpc ifFileExistsRelativePath(StringRequest) returns (BooleanResponse);
// Open a file in editor by a relative path
rpc openFileRelativePath(StringRequest) returns (Empty);
}
// Response for refreshRules operation
@@ -87,12 +90,19 @@ message RelativePaths {
repeated string paths = 1;
}
// Enum for file search type filtering
enum FileSearchType {
FILE = 0;
FOLDER = 1;
}
// Request for file search operations
message FileSearchRequest {
Metadata metadata = 1;
string query = 2; // Search query string
optional string mentions_request_id = 3; // Optional request ID for tracking requests
optional int32 limit = 4; // Optional limit for results (default: 20)
optional FileSearchType selected_type = 5; // Optional selected type filter
}
// Result for file search operations
+17
View File
@@ -0,0 +1,17 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
// This is for use in integration tests to get the contents of the webview.
service TestingService {
rpc getWebviewHtml(GetWebviewHtmlRequest) returns (GetWebviewHtmlResponse);
}
message GetWebviewHtmlRequest {
}
message GetWebviewHtmlResponse {
optional string html = 1;
}
+34 -65
View File
@@ -24,6 +24,14 @@ interface ClineHandlerOptions {
clineAccountId?: string
}
interface ClineStreamUsageChunk extends OpenAI.CompletionUsage {
cost?: number
cost_details?: {
upstream_inference_cost?: number
downstream_inference_cost?: number
}
}
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
private clineAccountService = ClineAccountService.getInstance()
@@ -31,7 +39,6 @@ export class ClineHandler implements ApiHandler {
private client: OpenAI | undefined
private readonly _baseUrl = clineEnvConfig.apiBaseUrl
lastGenerationId?: string
private counter = 0
constructor(options: ClineHandlerOptions) {
this.options = options
@@ -55,8 +62,9 @@ export class ClineHandler implements ApiHandler {
"X-Cline-Version": extensionVersion,
},
})
} catch (error: any) {
throw new Error(`Error creating Cline client: ${error.message}`)
} catch (error) {
console.error(`Error creating Cline client: ${error.message}`)
throw error
}
}
// Ensure the client is always using the latest auth token
@@ -87,10 +95,10 @@ export class ClineHandler implements ApiHandler {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
console.error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
throw error
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
@@ -99,12 +107,9 @@ export class ClineHandler implements ApiHandler {
// Check for mid-stream error via finish_reason
const choice = chunk.choices?.[0]
// OpenRouter may return finish_reason = "error" with error details
if ((choice?.finish_reason as string) === "error") {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
if (choice?.finish_reason && String(choice?.finish_reason) === "error") {
if ("error" in choice && choice?.error) {
throw choice.error
} else {
throw new Error(
"Cline Mid-Stream Error: Stream terminated with error status but no error details provided",
@@ -130,38 +135,15 @@ export class ClineHandler implements ApiHandler {
}
}
if (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
// const provider = modelId.split("/")[0]
// // If provider is x-ai, set totalCost to 0 (we're doing a promo)
// if (provider === "x-ai") {
// totalCost = 0
// }
if (modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens:
(chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
const streamUsage = chunk.usage as ClineStreamUsageChunk | undefined
if (!didOutputUsage && streamUsage) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: streamUsage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (streamUsage.prompt_tokens || 0) - (streamUsage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: streamUsage.completion_tokens || 0,
totalCost: (streamUsage.cost || 0) + (streamUsage.cost_details?.upstream_inference_cost || 0),
}
didOutputUsage = true
}
@@ -195,27 +177,14 @@ export class ClineHandler implements ApiHandler {
})
const generation = response.data
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
@@ -226,7 +195,7 @@ export class ClineHandler implements ApiHandler {
}
getModel(): { id: string; info: ModelInfo } {
let modelId = this.options.openRouterModelId
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
+16 -42
View File
@@ -132,27 +132,14 @@ export class OpenRouterHandler implements ApiHandler {
}
if (!didOutputUsage && chunk.usage) {
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
didOutputUsage = true
}
@@ -174,27 +161,14 @@ export class OpenRouterHandler implements ApiHandler {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
+92
View File
@@ -0,0 +1,92 @@
import * as vscode from "vscode"
import {
migrateCustomInstructionsToGlobalRules,
migrateWelcomeViewCompleted,
migrateWorkspaceToGlobalStorage,
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { Logger } from "./services/logging/Logger"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { WebviewProviderType } from "./shared/webview/types"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { telemetryService } from "./services/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
/**
* Performs intialization for Cline that is common to all platforms.
*
* @param context
* @returns The webview provider
*/
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
// Initialize PostHog client provider
const distinctId = context.globalState.get<string>("cline.distinctId")
PostHogClientProvider.getInstance(distinctId)
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
await migrateWelcomeViewCompleted(context)
// Migrate workspace storage values back to global storage (reverting previous migration)
await migrateWorkspaceToGlobalStorage(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
const sidebarWebview = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
await showVersionUpdateAnnouncement(context)
telemetryService.captureExtensionActivated()
return sidebarWebview
}
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Use the same condition as announcements: focus when there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId(context)
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
const message = previousVersion
? `Cline has been updated to v${currentVersion}`
: `Welcome to Cline v${currentVersion}`
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200))
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
PostHogClientProvider.getInstance().dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
}
@@ -0,0 +1,101 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
import { Controller } from "@core/controller"
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
import * as pathUtils from "@utils/path"
describe("ifFileExistsRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let getWorkspacePathStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(console, "error")
})
afterEach(() => {
sandbox.restore()
})
it("should return BooleanResponse with boolean value", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
// The result should be a BooleanResponse object
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
})
it("should return false and log error when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
consoleErrorStub.resetHistory()
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
expect(consoleErrorStub.called).to.be.true
}
})
it("should return false when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
}
})
it("should handle valid relative paths correctly", async () => {
getWorkspacePathStub.resolves("/workspace")
// Test with valid workspace-relative paths only
const validPaths = ["src/file.ts", "./src/file.ts", "package.json", ".gitignore", "src/components/ui/Button/Button.tsx"]
for (const testPath of validPaths) {
const request = StringRequest.create({
value: testPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
// Each should return a BooleanResponse
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
}
// Verify that getWorkspacePath was called for each path
expect(getWorkspacePathStub.callCount).to.equal(validPaths.length)
})
})
@@ -0,0 +1,117 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { openFileRelativePath } from "../openFileRelativePath"
import { Controller } from "@core/controller"
import { StringRequest, Empty } from "@shared/proto/cline/common"
import * as openFileIntegration from "@integrations/misc/open-file"
import * as pathUtils from "@utils/path"
import * as path from "path"
describe("openFileRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let openFileIntegrationStub: sinon.SinonStub
let getWorkspacePathStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Stub the openFileIntegration function
openFileIntegrationStub = sandbox.stub(openFileIntegration, "openFile")
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(console, "error")
})
afterEach(() => {
sandbox.restore()
})
it("should return Empty response on successful execution", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
})
it("should call openFileIntegration with absolute path when relative path is provided", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/Test.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
it("should not call openFileIntegration when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.called).to.be.false
openFileIntegrationStub.resetHistory()
}
})
it("should return Empty and log error when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
consoleErrorStub.resetHistory()
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
expect(consoleErrorStub.called).to.be.true
expect(openFileIntegrationStub.called).to.be.false
}
})
it("should handle nested directory paths", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/ui/Button/Button.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
})
@@ -0,0 +1,35 @@
import * as path from "path"
import * as fs from "fs"
import { Controller } from ".."
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
/**
* Check if a file exists in the project using a relative path
* @param controller The controller instance
* @param request The request containing the relative file path to check
* @returns BooleanResponse indicating whether the file exists
*/
export async function ifFileExistsRelativePath(_controller: Controller, request: StringRequest): Promise<BooleanResponse> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
// If no workspace is open, return false
console.error("Error in ifFileExistsRelativePath: No workspace path available") // TODO
return BooleanResponse.create({ value: false })
}
if (!request.value) {
// If no path provided, return false
return BooleanResponse.create({ value: false })
}
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
// Check if the file exists
try {
return BooleanResponse.create({ value: fs.statSync(absolutePath).isFile() })
} catch {
return BooleanResponse.create({ value: false })
}
}
@@ -0,0 +1,30 @@
import * as path from "path"
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { getWorkspacePath } from "@utils/path"
/**
* Opens a file in the editor by a relative path
* @param controller The controller instance
* @param request The request message containing the relative file path in the 'value' field
* @returns Empty response
*/
export async function openFileRelativePath(_controller: Controller, request: StringRequest): Promise<Empty> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
console.error("Error in openFileRelativePath: No workspace path available")
return Empty.create()
}
if (request.value) {
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
// Open the file using the existing integration
openFileIntegration(absolutePath)
}
return Empty.create()
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { Controller } from ".."
import { FileSearchRequest, FileSearchResults } from "@shared/proto/cline/file"
import { FileSearchRequest, FileSearchResults, FileSearchType } from "@shared/proto/cline/file"
import { searchWorkspaceFiles } from "@services/search/file-search"
import { getWorkspacePath } from "@utils/path"
import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/file/search-result-conversion"
@@ -23,11 +23,20 @@ export async function searchFiles(_controller: Controller, request: FileSearchRe
}
try {
// Map enum to string for the search service
let selectedTypeString: "file" | "folder" | undefined = undefined
if (request.selectedType === FileSearchType.FILE) {
selectedTypeString = "file"
} else if (request.selectedType === FileSearchType.FOLDER) {
selectedTypeString = "folder"
}
// Call file search service with query from request
const searchResults = await searchWorkspaceFiles(
request.query || "",
workspacePath,
request.limit || 20, // Use default limit of 20 if not specified
selectedTypeString,
)
// Convert search results to proto FileInfo objects using the conversion function
@@ -1,58 +0,0 @@
import { Controller } from "../index"
import { EmptyRequest, StringArray } from "@shared/proto/cline/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active subscriptions
const activeWorkspaceUpdateSubscriptions = new Set<StreamingResponseHandler<StringArray>>()
/**
* Subscribe to workspace file updates
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToWorkspaceUpdates(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<StringArray>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeWorkspaceUpdateSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeWorkspaceUpdateSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "workspace_update_subscription" }, responseStream)
}
}
/**
* Send a workspace update event to all active subscribers
* @param filePaths Array of file paths to send
*/
export async function sendWorkspaceUpdateEvent(filePaths: string[]): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeWorkspaceUpdateSubscriptions).map(async (responseStream) => {
try {
const event = StringArray.create({
values: filePaths,
})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending workspace update event:", error)
// Remove the subscription if there was an error
activeWorkspaceUpdateSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
-5
View File
@@ -9,7 +9,6 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { buildApiHandler } from "@api/index"
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
import { downloadTask } from "@integrations/misc/export-markdown"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { ClineAccountService } from "@services/account/ClineAccountService"
import { McpHub } from "@services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "@shared/api"
@@ -47,7 +46,6 @@ export class Controller {
private disposables: vscode.Disposable[] = []
task?: Task
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
readonly cacheService: CacheService
@@ -92,7 +90,6 @@ export class Controller {
}
}
this.workspaceTracker = new WorkspaceTracker()
this.mcpHub = new McpHub(
() => ensureMcpServersDirectoryExists(),
() => ensureSettingsDirectoryExists(this.context),
@@ -122,7 +119,6 @@ export class Controller {
x.dispose()
}
}
this.workspaceTracker.dispose()
this.mcpHub.dispose()
console.error("Controller disposed")
@@ -201,7 +197,6 @@ export class Controller {
this.task = new Task(
this.context,
this.mcpHub,
this.workspaceTracker,
(historyItem) => this.updateTaskHistory(historyItem),
() => this.postStateToWebview(),
(taskId) => this.reinitExistingTaskFromId(taskId),
@@ -3,6 +3,7 @@ import { Controller } from "../index"
import { EmptyRequest } from "@shared/proto/cline/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
import { State } from "@shared/proto/cline/state"
import { ExtensionState } from "@/shared/ExtensionMessage"
// Keep track of active state subscriptions by controller ID
const activeStateSubscriptions = new Map<string, StreamingResponseHandler<State>>()
@@ -52,7 +53,7 @@ export async function subscribeToState(
* @param controllerId The ID of the controller to send the state to
* @param state The state to send
*/
export async function sendStateUpdate(controllerId: string, state: any): Promise<void> {
export async function sendStateUpdate(controllerId: string, state: ExtensionState): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeStateSubscriptions.get(controllerId)
+10 -9
View File
@@ -1,16 +1,17 @@
import type { Controller } from "../index"
import { WebviewProvider } from "@/core/webview"
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { WebviewProviderType } from "@/shared/webview/types"
import type { Controller } from "../index"
/**
* Initialize webview when it launches
* @param controller The controller instance
* @param request The empty request
* @returns Empty response
* Returns the HTML content of the webview.
*
* This is only used by the standalone service. The Vscode extension gets the HTML directly from the webview when it
* resolved through `resolveWebviewView()`.
*/
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
const webviewProvider = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
const webviewProvider = WebviewProvider.getLastActiveInstance()
if (!webviewProvider) {
throw new Error("No active webview")
}
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
}
@@ -19,9 +19,6 @@ import { refreshBasetenModels } from "../models/refreshBasetenModels"
*/
export async function initializeWebview(controller: Controller, request: EmptyRequest): Promise<Empty> {
try {
// Populate file paths for workspace tracker (don't await)
controller.workspaceTracker?.populateFilePaths()
// Post last cached models in case the call to endpoint fails
controller.readOpenRouterModels().then((openRouterModels) => {
if (openRouterModels) {
+18 -2
View File
@@ -6,7 +6,6 @@ import { mentionRegexGlobal } from "@shared/context-mentions"
import fs from "fs/promises"
import { extractTextFromFile } from "@integrations/misc/extract-text"
import { isBinaryFile } from "isbinaryfile"
import { getWorkspaceProblemsString } from "@/integrations/diagnostics"
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
import { getCommitInfo } from "@utils/git"
import { getWorkingState } from "@utils/git"
@@ -15,6 +14,8 @@ import { getCwd } from "@/utils/path"
import { openExternal } from "@utils/env"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { DiagnosticSeverity } from "@/shared/proto/index.host"
export async function openMention(mention?: string): Promise<void> {
if (!mention) {
@@ -89,6 +90,14 @@ export async function parseMentions(
const uniqueMentions = Array.from(new Set(mentions))
for (const mention of uniqueMentions) {
// Safety guard: skip a bare "/" mention. This can surface from parsed strings or tool output and would resolve to the
// workspace root. Expanding it would scan the entire project, inflate context, and can trigger recursive loops.
// If root-level expansion is ever desired, gate it behind an explicit syntax (e.g. "@root" or "@folder:/")
// and enforce strict size/.clineignore limits instead.
if (mention === "/") {
continue
}
if (mention.startsWith("http")) {
let result: string
if (launchBrowserError) {
@@ -225,7 +234,14 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
}
async function getWorkspaceProblems(): Promise<string> {
return await getWorkspaceProblemsString()
const response = await HostProvider.workspace.getDiagnostics({})
if (response.fileDiagnostics.length === 0) {
return "No errors or warnings detected."
}
return diagnosticsToProblemsString(response.fileDiagnostics, [
DiagnosticSeverity.DIAGNOSTIC_ERROR,
DiagnosticSeverity.DIAGNOSTIC_WARNING,
])
}
function isFileMention(mention: string): boolean {
-9
View File
@@ -12,7 +12,6 @@ import { FileContextTracker } from "@core/context/context-tracking/FileContextTr
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import { extractTextFromFile, processFilesIntoText } from "@integrations/misc/extract-text"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { McpHub } from "@services/mcp/McpHub"
@@ -90,7 +89,6 @@ export class ToolExecutor {
private mcpHub: McpHub,
private fileContextTracker: FileContextTracker,
private clineIgnoreController: ClineIgnoreController,
private workspaceTracker: WorkspaceTracker,
private contextManager: ContextManager,
private cacheService: CacheService,
@@ -796,10 +794,6 @@ export class ToolExecutor {
)
}
if (!fileExists) {
this.workspaceTracker.populateFilePaths()
}
await this.diffViewProvider.reset()
await this.saveCheckpoint()
@@ -1431,9 +1425,6 @@ export class ToolExecutor {
this.taskState.didRejectTool = true
}
// Re-populate file paths in case the command modified the workspace (vscode listeners do not trigger unless the user manually creates/deletes files)
this.workspaceTracker.populateFilePaths()
this.pushToolResult(result, block)
await this.saveCheckpoint()
-5
View File
@@ -75,7 +75,6 @@ import {
} from "@core/storage/disk"
import { getGlobalState } from "@core/storage/state"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { McpHub } from "@services/mcp/McpHub"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily, isNextGenModelFamily } from "@utils/model-utils"
@@ -110,7 +109,6 @@ export class Task {
// Core dependencies
private context: vscode.ExtensionContext
private mcpHub: McpHub
private workspaceTracker: WorkspaceTracker
// Service handlers
api: ApiHandler
@@ -148,7 +146,6 @@ export class Task {
constructor(
context: vscode.ExtensionContext,
mcpHub: McpHub,
workspaceTracker: WorkspaceTracker,
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
postStateToWebview: () => Promise<void>,
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
@@ -175,7 +172,6 @@ export class Task {
this.taskState = new TaskState()
this.context = context
this.mcpHub = mcpHub
this.workspaceTracker = workspaceTracker
this.updateTaskHistory = updateTaskHistory
this.postStateToWebview = postStateToWebview
this.reinitExistingTaskFromId = reinitExistingTaskFromId
@@ -328,7 +324,6 @@ export class Task {
this.mcpHub,
this.fileContextTracker,
this.clineIgnoreController,
this.workspaceTracker,
this.contextManager,
this.cacheService,
this.autoApprovalSettings,
+16 -78
View File
@@ -1,10 +1,10 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import assert from "node:assert"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
import assert from "node:assert"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
@@ -12,33 +12,26 @@ import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChat
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import {
migrateCustomInstructionsToGlobalRules,
migrateWelcomeViewCompleted,
migrateWorkspaceToGlobalStorage,
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { Logger } from "./services/logging/Logger"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { cleanupTestMode, initializeTestMode } from "./services/test/TestMode"
import { WebviewProviderType } from "./shared/webview/types"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import type { ExtensionContext } from "vscode"
import { initialize, tearDown } from "./common"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { GitCommitGenerator } from "./integrations/git/commit-message-generator"
import { AuthService } from "./services/auth/AuthService"
import { telemetryService } from "./services/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
import { getLatestAnnouncementId } from "./utils/announcements"
import { ShowMessageType } from "./shared/proto/host/window"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -51,31 +44,12 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
export async function activate(context: vscode.ExtensionContext) {
maybeSetupHostProviders(context)
setupHostProvider(context)
// Initialize PostHog client provider
const distinctId = context.globalState.get<string>("cline.distinctId")
PostHogClientProvider.getInstance(distinctId)
const sidebarWebview = (await initialize(context)) as VscodeWebviewProvider
Logger.log("Cline extension activated")
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
await migrateWelcomeViewCompleted(context)
// Migrate workspace storage values back to global storage (reverting previous migration)
await migrateWorkspaceToGlobalStorage(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
const sidebarWebview = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
const testModeWatchers = await initializeTestMode(sidebarWebview)
// Initialize test mode and add disposables to context
context.subscriptions.push(...testModeWatchers)
@@ -88,37 +62,6 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Use the same condition as announcements: focus when there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId(context)
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
const message = previousVersion
? `Cline has been updated to v${currentVersion}`
: `Welcome to Cline v${currentVersion}`
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200))
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
telemetryService.captureExtensionActivated()
context.subscriptions.push(
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
console.log("[DEBUG] plusButtonClicked", webview)
@@ -640,26 +583,21 @@ export async function activate(context: vscode.ExtensionContext) {
return createClineAPI(sidebarWebview.controller)
}
function maybeSetupHostProviders(context: ExtensionContext) {
if (!HostProvider.isInitialized()) {
console.log("Setting up vscode host providers...")
function setupHostProvider(context: ExtensionContext) {
console.log("Setting up vscode host providers...")
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
const createDiffView = () => new VscodeDiffViewProvider()
const outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
const createDiffView = () => new VscodeDiffViewProvider()
const outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
const getCallbackUri = async () => `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
}
const getCallbackUri = async () => `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
}
// This method is called when your extension is deactivated
export async function deactivate() {
PostHogClientProvider.getInstance().dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
tearDown()
// Clean up test mode
cleanupTestMode()
-25
View File
@@ -1,6 +1,5 @@
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { DiagnosticSeverity } from "@/shared/proto/host/workspace"
import { status } from "@grpc/grpc-js"
export class ExternalDiffViewProvider extends DiffViewProvider {
@@ -78,30 +77,6 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
return (await HostProvider.diff.getDocumentText({ diffId: this.activeDiffEditorId })).content
}
protected override async getNewDiagnosticProblems(): Promise<string> {
// Get diagnostics using the HostBridge workspace service
const response = await HostProvider.workspace.getDiagnostics({})
if (response.fileDiagnostics.length === 0) {
return ""
}
let result = ""
for (const fileDiagnostics of response.fileDiagnostics) {
const errors = fileDiagnostics.diagnostics.filter((d) => d.severity === DiagnosticSeverity.DIAGNOSTIC_ERROR)
if (errors.length > 0) {
result += `\n\n${fileDiagnostics.filePath}`
for (const diagnostic of errors) {
const line = (diagnostic.range?.start?.line || 0) + 1 // Proto lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}Error] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
}
protected override async closeDiffView(): Promise<void> {
if (!this.activeDiffEditorId) {
return
@@ -3,13 +3,11 @@ import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@/hosts/vscode/DecorationController"
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import { diagnosticsToProblemsString, getNewDiagnostics } from "./diagnostics"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
export class VscodeDiffViewProvider extends DiffViewProvider {
private activeDiffEditor?: vscode.TextEditor
private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = []
private fadedOverlayController?: DecorationController
private activeLineController?: DecorationController
@@ -18,8 +16,6 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
if (!this.absolutePath) {
throw new Error("No file path set")
}
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if cline needs to fix anything
this.preDiagnostics = vscode.languages.getDiagnostics()
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.documentWasOpen = false
@@ -164,16 +160,6 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
return this.activeDiffEditor.document.getText()
}
protected override async getNewDiagnosticProblems(): Promise<string> {
// Get the diagnostics after changing the document.
const postDiagnostics = vscode.languages.getDiagnostics()
const newProblems = getNewDiagnostics(this.preDiagnostics, postDiagnostics)
// Only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
// will be empty string if no errors
const problems = await diagnosticsToProblemsString(newProblems, [vscode.DiagnosticSeverity.Error])
return problems
}
protected override async saveDocument(): Promise<Boolean> {
if (!this.activeDiffEditor) {
return false
@@ -206,6 +192,5 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
this.activeDiffEditor = undefined
this.fadedOverlayController = undefined
this.activeLineController = undefined
this.preDiagnostics = []
}
}
-109
View File
@@ -1,109 +0,0 @@
import * as vscode from "vscode"
import * as path from "path"
import deepEqual from "fast-deep-equal"
import { getCwd } from "@/utils/path"
export function getNewDiagnostics(
oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
): [vscode.Uri, vscode.Diagnostic[]][] {
const newProblems: [vscode.Uri, vscode.Diagnostic[]][] = []
const oldMap = new Map(oldDiagnostics)
for (const [uri, newDiags] of newDiagnostics) {
const oldDiags = oldMap.get(uri) || []
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
if (newProblemsForUri.length > 0) {
newProblems.push([uri, newProblemsForUri])
}
}
return newProblems
}
// Usage:
// const oldDiagnostics = // ... your old diagnostics array
// const newDiagnostics = // ... your new diagnostics array
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
// Example usage with mocks:
//
// // Mock old diagnostics
// const oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]]
// ];
//
// // Mock new diagnostics
// const newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error),
// new vscode.Diagnostic(new vscode.Range(2, 2, 2, 12), "New error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]],
// [vscode.Uri.file("/path/to/file3.ts"), [
// new vscode.Diagnostic(new vscode.Range(1, 1, 1, 11), "New error in file3", vscode.DiagnosticSeverity.Error)
// ]]
// ];
//
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
//
// console.log("New problems:");
// for (const [uri, diagnostics] of newProblems) {
// console.log(`File: ${uri.fsPath}`);
// for (const diagnostic of diagnostics) {
// console.log(`- ${diagnostic.message} (${diagnostic.range.start.line}:${diagnostic.range.start.character})`);
// }
// }
//
// // Expected output:
// // New problems:
// // File: /path/to/file1.ts
// // - New error in file1 (2:2)
// // File: /path/to/file3.ts
// // - New error in file3 (1:1)
// will return empty string if no problems with the given severity are found
export async function diagnosticsToProblemsString(
diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
severities: vscode.DiagnosticSeverity[],
): Promise<string> {
const cwd = await getCwd()
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
if (problems.length > 0) {
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
for (const diagnostic of problems) {
let label: string
switch (diagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
label = "Error"
break
case vscode.DiagnosticSeverity.Warning:
label = "Warning"
break
case vscode.DiagnosticSeverity.Information:
label = "Information"
break
case vscode.DiagnosticSeverity.Hint:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
}
@@ -0,0 +1,5 @@
import { GetWebviewHtmlRequest, GetWebviewHtmlResponse } from "@/shared/proto/index.host"
export async function getWebviewHtml(_: GetWebviewHtmlRequest): Promise<GetWebviewHtmlResponse> {
throw new Error("Unimplemented")
}
@@ -63,7 +63,7 @@ describe("Hostbridge - Window - getOpenTabs", () => {
return response.paths.length === 2
},
{
timeout: 2000,
timeout: 4000,
interval: 50,
},
)
@@ -93,7 +93,7 @@ describe("Hostbridge - Window - getOpenTabs", () => {
return response.paths.length === 3
},
{
timeout: 2000,
timeout: 4000,
interval: 50,
},
)
@@ -0,0 +1,464 @@
import { describe, it, beforeEach } from "mocha"
import { expect } from "chai"
import { getNewDiagnostics, diagnosticsToProblemsString } from "../"
import { DiagnosticSeverity, FileDiagnostics } from "@shared/proto/index.host"
import * as sinon from "sinon"
import * as pathUtils from "@/utils/path"
describe("Diagnostics Tests", () => {
describe("getNewDiagnostics", () => {
it("should return empty array when both old and new diagnostics are empty", () => {
const oldDiagnostics: FileDiagnostics[] = []
const newDiagnostics: FileDiagnostics[] = []
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.deep.equal([])
})
it("should return all diagnostics when old diagnostics is empty", () => {
const oldDiagnostics: FileDiagnostics[] = []
const newDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
]
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.deep.equal(newDiagnostics)
})
it("should return empty array when new diagnostics is empty", () => {
const oldDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
},
],
},
]
const newDiagnostics: FileDiagnostics[] = []
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.deep.equal([])
})
it("should return only new diagnostics not present in old diagnostics", () => {
const oldDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Old error",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
]
const newDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Old error",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
message: "New warning",
range: {
start: { line: 5, character: 5 },
end: { line: 5, character: 15 },
},
},
],
},
]
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.have.lengthOf(1)
expect(result[0].filePath).to.equal("/path/to/file1.ts")
expect(result[0].diagnostics).to.have.lengthOf(1)
expect(result[0].diagnostics[0].message).to.equal("New warning")
})
it("should handle multiple files correctly", () => {
const oldDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
},
],
},
]
const newDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
},
],
},
{
filePath: "/path/to/file2.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file2",
},
],
},
]
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.have.lengthOf(1)
expect(result[0].filePath).to.equal("/path/to/file2.ts")
})
it("should handle diagnostics with source and code properties", () => {
const oldDiagnostics: FileDiagnostics[] = []
const newDiagnostics: FileDiagnostics[] = [
{
filePath: "/path/to/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Type error",
source: "typescript",
range: {
start: { line: 10, character: 5 },
end: { line: 10, character: 20 },
},
},
],
},
]
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
expect(result).to.deep.equal(newDiagnostics)
})
})
describe("diagnosticsToProblemsString", () => {
let getCwdStub: sinon.SinonStub
beforeEach(() => {
getCwdStub = sinon.stub(pathUtils, "getCwd").resolves("/workspace")
})
afterEach(() => {
sinon.restore()
})
it("should return empty string when diagnostics array is empty", async () => {
const diagnostics: FileDiagnostics[] = []
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("")
})
it("should return empty string when no diagnostics match the severity filter", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
message: "Warning message",
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("")
})
it("should format error diagnostics correctly with line numbers", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Type error",
range: {
start: { line: 9, character: 5 },
end: { line: 9, character: 20 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [Error] Line 10: Type error")
})
it("should handle diagnostics without range information", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "File-level error",
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [Error] Line : File-level error")
})
it("should handle diagnostics with missing start property in range", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error with partial range",
range: {} as any, // Simulating missing start property
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [Error] Line : Error with partial range")
})
it("should include source information when available", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Type error",
source: "typescript",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [typescript Error] Line 1: Type error")
})
it("should handle multiple severities", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error message",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
message: "Warning message",
range: {
start: { line: 5, character: 0 },
end: { line: 5, character: 10 },
},
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
message: "Info message",
range: {
start: { line: 10, character: 0 },
end: { line: 10, character: 10 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR, DiagnosticSeverity.DIAGNOSTIC_WARNING]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("src/file1.ts\n- [Error] Line 1: Error message\n- [Warning] Line 6: Warning message")
})
it("should handle multiple files", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file1",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
{
filePath: "/workspace/src/file2.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error in file2",
range: {
start: { line: 5, character: 0 },
end: { line: 5, character: 10 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal(
"src/file1.ts\n- [Error] Line 1: Error in file1\n\nsrc/file2.ts\n- [Error] Line 6: Error in file2",
)
})
it("should handle absolute paths outside workspace", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/other/path/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error outside workspace",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal("../other/path/file1.ts\n- [Error] Line 1: Error outside workspace")
})
it("should handle all diagnostic severity types", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error",
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
message: "Warning",
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 10 } },
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
message: "Information",
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
},
{
severity: DiagnosticSeverity.DIAGNOSTIC_HINT,
message: "Hint",
range: { start: { line: 3, character: 0 }, end: { line: 3, character: 10 } },
},
],
},
]
const severities = [
DiagnosticSeverity.DIAGNOSTIC_ERROR,
DiagnosticSeverity.DIAGNOSTIC_WARNING,
DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
DiagnosticSeverity.DIAGNOSTIC_HINT,
]
const result = await diagnosticsToProblemsString(diagnostics, severities)
expect(result).to.equal(
"src/file1.ts\n- [Error] Line 1: Error\n- [Warning] Line 2: Warning\n- [Information] Line 3: Information\n- [Hint] Line 4: Hint",
)
})
it("should handle edge case with line number 0", async () => {
const diagnostics: FileDiagnostics[] = [
{
filePath: "/workspace/src/file1.ts",
diagnostics: [
{
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
message: "Error on first line",
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 10 },
},
},
],
},
]
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
const result = await diagnosticsToProblemsString(diagnostics, severities)
// Line 0 should be displayed as Line 1 (1-indexed)
expect(result).to.equal("src/file1.ts\n- [Error] Line 1: Error on first line")
})
})
})
+54 -39
View File
@@ -1,49 +1,48 @@
import { HostProvider } from "@/hosts/host-provider"
import { GetDiagnosticsRequest, DiagnosticSeverity } from "@/shared/proto/host/workspace"
import { Metadata } from "@/shared/proto/cline/common"
import * as path from "path"
import deepEqual from "fast-deep-equal"
import { getCwd } from "@/utils/path"
import { Diagnostic, DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.host"
/**
* Host-agnostic function to get workspace problems as a formatted string
* Used by @problems mention for cross-host compatibility
*/
export async function getWorkspaceProblemsString(): Promise<string> {
const response = await HostProvider.workspace.getDiagnostics(
GetDiagnosticsRequest.create({
metadata: Metadata.create({}),
}),
)
if (response.fileDiagnostics.length === 0) {
return "No errors or warnings detected."
export function getNewDiagnostics(oldDiagnostics: FileDiagnostics[], newDiagnostics: FileDiagnostics[]): FileDiagnostics[] {
const oldMap = new Map<string, Diagnostic[]>()
for (const diag of oldDiagnostics) {
oldMap.set(diag.filePath, diag.diagnostics)
}
let result = ""
for (const fileDiagnostics of response.fileDiagnostics) {
const problems = fileDiagnostics.diagnostics.filter(
(d) => d.severity === DiagnosticSeverity.DIAGNOSTIC_ERROR || d.severity === DiagnosticSeverity.DIAGNOSTIC_WARNING,
const newProblems: FileDiagnostics[] = []
for (const newDiags of newDiagnostics) {
const oldDiags = oldMap.get(newDiags.filePath) || []
const newProblemsForFile = newDiags.diagnostics.filter(
(newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)),
)
if (newProblemsForFile.length > 0) {
newProblems.push({ filePath: newDiags.filePath, diagnostics: newProblemsForFile })
}
}
return newProblems
}
// will return empty string if no problems with the given severity are found
export async function diagnosticsToProblemsString(
diagnostics: FileDiagnostics[],
severities: DiagnosticSeverity[],
): Promise<string> {
const cwd = await getCwd()
let result = ""
for (const fileDiagnostics of diagnostics) {
const problems = fileDiagnostics.diagnostics.filter((d) => severities.includes(d.severity))
if (problems.length > 0) {
result += `\n\n${fileDiagnostics.filePath}`
const filePath = path.relative(cwd, fileDiagnostics.filePath).toPosix()
result += `\n\n${filePath}`
for (const diagnostic of problems) {
let label: string
switch (diagnostic.severity) {
case DiagnosticSeverity.DIAGNOSTIC_ERROR:
label = "Error"
break
case DiagnosticSeverity.DIAGNOSTIC_WARNING:
label = "Warning"
break
case DiagnosticSeverity.DIAGNOSTIC_INFORMATION:
label = "Information"
break
case DiagnosticSeverity.DIAGNOSTIC_HINT:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = (diagnostic.range?.start?.line || 0) + 1 // Proto lines are 0-indexed
const label = severityToString(diagnostic.severity)
// Lines are 0-indexed
const line = diagnostic.range?.start ? `${diagnostic.range.start.line + 1}` : ""
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
@@ -51,3 +50,19 @@ export async function getWorkspaceProblemsString(): Promise<string> {
}
return result.trim()
}
function severityToString(severity: DiagnosticSeverity): string {
switch (severity) {
case DiagnosticSeverity.DIAGNOSTIC_ERROR:
return "Error"
case DiagnosticSeverity.DIAGNOSTIC_WARNING:
return "Warning"
case DiagnosticSeverity.DIAGNOSTIC_INFORMATION:
return "Information"
case DiagnosticSeverity.DIAGNOSTIC_HINT:
return "Hint"
default:
console.warn("Unhandled diagnostic severity level:", severity)
return "Diagnostic"
}
}
+25 -4
View File
@@ -1,12 +1,14 @@
import * as path from "path"
import * as fs from "fs/promises"
import { createDirectoriesForFile } from "@utils/fs"
import { arePathsEqual, getCwd } from "@utils/path"
import { getCwd } from "@utils/path"
import { formatResponse } from "@core/prompts/responses"
import * as diff from "diff"
import { detectEncoding } from "../misc/extract-text"
import * as iconv from "iconv-lite"
import { HostProvider } from "@/hosts/host-provider"
import { DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.host"
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
export abstract class DiffViewProvider {
editType?: "create" | "modify"
@@ -14,6 +16,7 @@ export abstract class DiffViewProvider {
originalContent: string | undefined
private createdDirs: string[] = []
protected documentWasOpen = false
private preDiagnostics: FileDiagnostics[] = []
protected relPath?: string
protected absolutePath?: string
protected fileEncoding: string = "utf8"
@@ -47,6 +50,8 @@ export abstract class DiffViewProvider {
if (!fileExists) {
await fs.writeFile(this.absolutePath, "")
}
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if cline needs to fix anything
this.preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
await this.openDiffEditor()
await this.scrollEditorToLine(0)
this.streamedLines = []
@@ -114,7 +119,16 @@ export abstract class DiffViewProvider {
* applying a fix, Cline won't be notified, which is generally fine since the
* initial fix is usually correct and it may just take time for linters to catch up.
*/
protected abstract getNewDiagnosticProblems(): Promise<string>
private async getNewDiagnosticProblems(): Promise<string> {
// Get the diagnostics after changing the document.
const postDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
const newProblems = getNewDiagnostics(this.preDiagnostics, postDiagnostics)
// Only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
// will be empty string if no errors
const problems = await diagnosticsToProblemsString(newProblems, [DiagnosticSeverity.DIAGNOSTIC_ERROR])
return problems
}
/**
* Save the contents of the diff editor UI to the file.
@@ -359,12 +373,19 @@ export abstract class DiffViewProvider {
// close editor if open?
async reset() {
this.editType = undefined
this.isEditing = false
this.editType = undefined
this.absolutePath = undefined
this.relPath = undefined
this.preDiagnostics = []
this.originalContent = undefined
this.createdDirs = []
this.fileEncoding = "utf8"
this.documentWasOpen = false
this.streamedLines = []
this.createdDirs = []
this.newContent = undefined
await this.resetDiffView()
}
@@ -1,139 +0,0 @@
import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "@services/glob/list-files"
import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates"
import { getCwd } from "@/utils/path"
import { isDirectory } from "@/utils/fs"
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
private cwd: string = ""
constructor() {
this.initializeCwd()
this.registerListeners()
}
private async initializeCwd() {
this.cwd = await getCwd()
}
private get activeFiles() {
return new Set(
vscode.window.tabGroups.activeTabGroup.tabs
.filter((tab) => tab.input instanceof vscode.TabInputText)
.map((tab) => (tab.input as vscode.TabInputText).uri.fsPath),
)
}
async populateFilePaths() {
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
if (!this.cwd) {
return
}
const [files, _] = await listFiles(this.cwd, true, 1_000)
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
this.workspaceDidUpdate()
}
private registerListeners() {
// Listen for file creation
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
// Listen for file deletion
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
// Listen for file renaming
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
// Listen for tab groups changes
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(this.workspaceDidUpdate.bind(this)))
/*
An event that is emitted when a workspace folder is added or removed.
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
because in that case the currently executing extensions (including the one that listens to this
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
to point to the first workspace folder.
*/
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
}
private async onFilesCreated(event: vscode.FileCreateEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.addFilePath(file.fsPath)
}),
)
this.workspaceDidUpdate()
}
private async onFilesDeleted(event: vscode.FileDeleteEvent) {
let updated = false
await Promise.all(
event.files.map(async (file) => {
if (await this.removeFilePath(file.fsPath)) {
updated = true
}
}),
)
if (updated) {
this.workspaceDidUpdate()
}
}
private async onFilesRenamed(event: vscode.FileRenameEvent) {
await Promise.all(
event.files.map(async (file) => {
await this.removeFilePath(file.oldUri.fsPath)
await this.addFilePath(file.newUri.fsPath)
}),
)
this.workspaceDidUpdate()
}
private async workspaceDidUpdate() {
if (!this.cwd) {
return
}
const filePaths = Array.from(new Set([...this.activeFiles, ...this.filePaths])).map((file) => {
const relativePath = path.relative(this.cwd, file).toPosix()
return file.endsWith("/") ? relativePath + "/" : relativePath
})
await sendWorkspaceUpdateEvent(filePaths)
}
private normalizeFilePath(filePath: string): string {
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
}
private async addFilePath(filePath: string): Promise<string> {
const normalizedPath = this.normalizeFilePath(filePath)
try {
const isDir = await isDirectory(normalizedPath)
const pathWithSlash = isDir && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
this.filePaths.add(pathWithSlash)
return pathWithSlash
} catch {
// If stat fails, assume it's a file (this can happen for newly created files)
this.filePaths.add(normalizedPath)
return normalizedPath
}
}
private async removeFilePath(filePath: string): Promise<boolean> {
const normalizedPath = this.normalizeFilePath(filePath)
return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
}
}
export default WorkspaceTracker
@@ -41,7 +41,6 @@ export class PostHogClientProvider {
// Initialize PostHog client
this.client = new PostHog(posthogConfig.apiKey, {
host: posthogConfig.host,
enableExceptionAutocapture: true,
})
vscode.env.onDidChangeTelemetryEnabled((isTelemetryEnabled) => {
+44 -13
View File
@@ -5,6 +5,9 @@ import * as childProcess from "child_process"
import * as readline from "readline"
import { getBinPath } from "../ripgrep"
import type { Fzf, FzfResultItem } from "fzf"
import { HostProvider } from "@/hosts/host-provider"
import { GetOpenTabsRequest } from "@/shared/proto/host/window"
import { isLocatedInWorkspace, asRelativePath } from "@/utils/path"
// Wrapper function for childProcess.spawn
export type SpawnFunction = typeof childProcess.spawn
@@ -90,10 +93,18 @@ export async function executeRipgrepForFiles(
})
}
// Get currently active/open files from VSCode tabs using hostbridge
async function getActiveFiles(): Promise<Set<string>> {
const request = GetOpenTabsRequest.create({})
const response = await HostProvider.window.getOpenTabs(request)
return new Set(response.paths)
}
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit: number = 20,
selectedType?: "file" | "folder",
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
try {
const rgPath = await getBinPath(vscode.env.appRoot)
@@ -102,34 +113,54 @@ export async function searchWorkspaceFiles(
throw new Error("Could not find ripgrep binary")
}
// Get currently active files and convert to search format
const activeFilePaths = await getActiveFiles()
const activeFiles: { path: string; type: "file" | "folder"; label?: string }[] = []
for (const filePath of activeFilePaths) {
if (await isLocatedInWorkspace(filePath)) {
const relativePath = await asRelativePath(filePath)
const normalizedPath = relativePath.toPosix()
activeFiles.push({
path: normalizedPath,
type: "file",
label: path.basename(normalizedPath),
})
}
}
// Get all files and directories
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
// If no query, just return the top items
// Combine active files with all items, removing duplicates (like the old WorkspaceTracker)
const combinedItems = [...activeFiles]
for (const item of allItems) {
if (!activeFiles.some((activeFile) => activeFile.path === item.path)) {
combinedItems.push(item)
}
}
// If no query, return the combined items
if (!query.trim()) {
return allItems.slice(0, limit)
if (selectedType === "file") {
return combinedItems.filter((item) => item.type === "file").slice(0, limit)
} else if (selectedType === "folder") {
return combinedItems.filter((item) => item.type === "folder").slice(0, limit)
}
return combinedItems.slice(0, limit)
}
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
// Get more (2x) results than needed for filtering, we pick the top half after sorting
const fzfModule = await import("fzf")
const fzf = new fzfModule.Fzf(allItems, {
const fzf = new fzfModule.Fzf(combinedItems, {
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
limit: limit * 2,
})
// The min threshold value will require some testing and tuning as the scores are exponential, and exaggerated
const MIN_SCORE_THRESHOLD = 100
// Filter results by score and map to original items
// Use exponential scaling for normalization
// This gives a more dramatic difference between good and bad matches
const filteredResults = fzf
.find(query)
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
.slice(0, limit)
const filteredResults = fzf.find(query).slice(0, limit)
// Verify if the path exists and is actually a directory
const verifiedResultsPromises = filteredResults.map(
+12 -16
View File
@@ -1,35 +1,29 @@
import { activate } from "@/extension"
import { Controller } from "@core/controller"
import { CacheService } from "@core/storage/CacheService"
import { initialize, tearDown } from "@/common"
import { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvider"
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
import { HostProvider } from "@/hosts/host-provider"
import { WebviewProviderType } from "@shared/webview/types"
import { v4 as uuidv4 } from "uuid"
import { startProtobusService } from "./protobus-service"
import { log } from "./utils"
import { extensionContext } from "./vscode-context"
import { startProtobusService } from "./protobus-service"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { WebviewProvider } from "@/core/webview"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
async function main() {
log("\n\n\nStarting cline-core service...\n\n\n")
AuthHandler.getInstance().setEnabled(true)
setupHostProvider()
// Set up global error handlers to prevent process crashes
setupGlobalErrorHandlers()
activate(extensionContext)
// Create and initialize cache service
const webviewProvider = await initialize(extensionContext)
// Create controller with cache service
const controller = new Controller(extensionContext, uuidv4())
startProtobusService(controller)
AuthHandler.getInstance().setEnabled(true)
startProtobusService(webviewProvider.controller)
}
function setupHostProvider() {
@@ -80,6 +74,8 @@ function setupGlobalErrorHandlers() {
process.on("SIGTERM", () => {
log("Received SIGTERM, shutting down gracefully...")
tearDown()
process.exit(0)
})
}
@@ -1,6 +1,6 @@
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { FileSearchRequest, RelativePathsRequest } from "@shared/proto/cline/file"
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
import { convertApiConfigurationToProto } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -279,7 +279,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, mode, apiConfiguration, openRouterModels, platform, localWorkflowToggles, globalWorkflowToggles } =
const { mode, apiConfiguration, openRouterModels, platform, localWorkflowToggles, globalWorkflowToggles } =
useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [isDraggingOver, setIsDraggingOver] = useState(false)
@@ -353,14 +353,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{ type: ContextMenuOptionType.Problems, value: "problems" },
{ type: ContextMenuOptionType.Terminal, value: "terminal" },
...gitCommits,
...filePaths
.map((file) => "/" + file)
.map((path) => ({
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: path,
})),
]
}, [filePaths, gitCommits])
}, [gitCommits])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -412,6 +406,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setSelectedType(type)
setSearchQuery("")
setSelectedMenuIndex(0)
// Trigger search with the selected type
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
setSearchLoading(true)
// Map ContextMenuOptionType to FileSearchType enum
let searchType = undefined
if (type === ContextMenuOptionType.File) {
searchType = FileSearchType.FILE
} else if (type === ContextMenuOptionType.Folder) {
searchType = FileSearchType.FOLDER
}
FileServiceClient.searchFiles(
FileSearchRequest.create({
query: "",
mentionsRequestId: "",
selectedType: searchType,
}),
)
.then((results) => {
setFileSearchResults((results.results || []) as SearchResult[])
setSearchLoading(false)
})
.catch((error) => {
console.error("Error searching files:", error)
setFileSearchResults([])
setSearchLoading(false)
})
}
return
}
}
@@ -749,6 +773,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
FileSearchRequest.create({
query: query,
mentionsRequestId: query,
selectedType: undefined, // No type filter for general search
}),
)
.then((results) => {
@@ -9,8 +9,9 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import MermaidBlock from "@/components/common/MermaidBlock"
import { WithCopyButton } from "./CopyButton"
import { StateServiceClient } from "@/services/grpc-client"
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
// Styled component for Act Mode text with more specific styling
const ActModeHighlight: React.FC = () => {
@@ -320,6 +321,39 @@ const PreWithCopyButton = ({
)
}
/**
* Custom remark plugin that detects file paths in inline code blocks
* and marks them with metadata for later rendering
*/
const remarkFilePathDetection = () => {
return async (tree: Node) => {
const fileNameRegex = /^(?!\/)[\w\-./]+(?<!\/)$/
const inlineCodeNodes: any[] = []
const filePathPromises: Promise<void>[] = []
// Collect all inline code nodes that might be file paths
visit(tree, "inlineCode", (node: Node & { value: string; data?: any }) => {
if (fileNameRegex.test(node.value) && !node.value.includes("\n")) {
const promise = FileServiceClient.ifFileExistsRelativePath(StringRequest.create({ value: node.value }))
.then((exists) => {
if (exists.value) {
node.data = node.data || {}
node.data.hProperties = node.data.hProperties || {}
node.data.hProperties["data-is-file-path"] = "true"
}
})
.catch((err) => {
console.debug(`Failed to check file existence for ${node.value}:`, err)
})
filePathPromises.push(promise)
}
})
await Promise.all(filePathPromises)
}
}
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
const { theme } = useExtensionState()
@@ -328,6 +362,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
remarkPreventBoldFilenames,
remarkUrlToLink,
remarkHighlightActMode,
remarkFilePathDetection,
() => {
return (tree) => {
visit(tree, "code", (node: any) => {
@@ -361,12 +396,31 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
</PreWithCopyButton>
)
},
code: (props: ComponentProps<"code">) => {
code: (props: ComponentProps<"code"> & { [key: string]: any }) => {
const className = props.className || ""
if (className.includes("language-mermaid")) {
const codeText = String(props.children || "")
return <MermaidBlock code={codeText} />
}
// Check if this is a file path (metadata is converted to data- attributes by rehype-react)
if (props["data-is-file-path"]) {
// Extract the file path from the code element's children
const filePath = typeof props.children === "string" ? props.children : String(props.children || "")
return (
<>
<code {...props} />
<button
type="button"
className="codicon codicon-link-external bg-transparent border-0 appearance-none p-0 ml-0.5 leading-none align-middle opacity-70 hover:opacity-100 transition-opacity text-[1em] relative top-[1px] text-[var(--vscode-textPreformat-foreground)] translate-y-[-2px]"
onClick={() => FileServiceClient.openFileRelativePath({ value: filePath })}
title={`Open ${filePath} in editor`}
/>
</>
)
}
return <code {...props} />
},
strong: (props: ComponentProps<"strong">) => {
@@ -49,7 +49,7 @@ const CustomFilterRadio = ({ checked, onChange, icon, label }: CustomFilterRadio
const HistoryView = ({ onDone }: HistoryViewProps) => {
const extensionStateContext = useExtensionState()
const { taskHistory, filePaths, onRelinquishControl } = extensionStateContext
const { taskHistory, onRelinquishControl } = extensionStateContext
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
@@ -46,7 +46,6 @@ interface ExtensionStateContextType extends ExtensionState {
huggingFaceModels: Record<string, ModelInfo>
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
totalTasksSize: number | null
availableTerminalProfiles: TerminalProfile[]
@@ -205,7 +204,6 @@ export const ExtensionStateContextProvider: React.FC<{
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
const [theme, setTheme] = useState<Record<string, string>>()
const [filePaths, setFilePaths] = useState<string[]>([])
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
@@ -383,18 +381,6 @@ export const ExtensionStateContextProvider: React.FC<{
},
})
// Subscribe to workspace file updates
workspaceUpdatesUnsubscribeRef.current = FileServiceClient.subscribeToWorkspaceUpdates(EmptyRequest.create({}), {
onResponse: (response) => {
console.log("[DEBUG] Received workspace update event from gRPC stream")
setFilePaths(response.values || [])
},
onError: (error) => {
console.error("Error in workspace updates subscription:", error)
},
onComplete: () => {},
})
// Set up settings button clicked subscription
settingsButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToSettingsButtonClicked(
WebviewProviderTypeRequest.create({
@@ -653,7 +639,6 @@ export const ExtensionStateContextProvider: React.FC<{
huggingFaceModels,
mcpServers,
mcpMarketplaceCatalog,
filePaths,
totalTasksSize,
availableTerminalProfiles,
showMcp,
+18 -14
View File
@@ -114,23 +114,38 @@ export function getContextMenuOptions(
description: "Current uncommitted changes",
}
const searchResultItems: ContextMenuQueryItem[] = dynamicSearchResults.map((result) => {
const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
const item = {
type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: formattedPath,
label: result.label || path.basename(result.path),
description: formattedPath,
}
return item
})
if (query === "") {
if (selectedType === ContextMenuOptionType.File) {
const files = queryItems
const files = searchResultItems
.filter((item) => item.type === ContextMenuOptionType.File)
.map((item) => ({
type: item.type,
value: item.value,
label: item.label,
description: item.description,
}))
return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }]
}
if (selectedType === ContextMenuOptionType.Folder) {
const folders = queryItems
.filter((item) => item.type === ContextMenuOptionType.Folder)
const folders = searchResultItems
.filter((item) => item.type !== ContextMenuOptionType.File)
.map((item) => ({
type: ContextMenuOptionType.Folder,
value: item.value,
label: item.label,
description: item.description,
}))
return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }]
}
@@ -207,17 +222,6 @@ export function getContextMenuOptions(
item.type !== ContextMenuOptionType.Git,
)
const searchResultItems = dynamicSearchResults.map((result) => {
const formattedPath = result.path.startsWith("/") ? result.path : `/${result.path}`
const item = {
type: result.type === "folder" ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
value: formattedPath,
label: result.label || path.basename(result.path),
description: formattedPath,
}
return item
})
// If we have dynamic search results, prioritize those
if (dynamicSearchResults.length > 0) {
// Only show suggestions and dynamic results