mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1651c5233 | ||
|
|
028fe70019 | ||
|
|
41c5e809bd | ||
|
|
2aa5156905 | ||
|
|
51b619e0d5 | ||
|
|
85fb76a996 | ||
|
|
d73a7cfd06 | ||
|
|
489dfbc932 | ||
|
|
314c416788 | ||
|
|
3b19c2ec95 | ||
|
|
e04cbea504 | ||
|
|
affac119f5 | ||
|
|
3847a2545c | ||
|
|
15593bac2a | ||
|
|
84267efb9e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding safety guard for workspace root
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user