Compare commits

...

9 Commits

Author SHA1 Message Date
Dennis Bartlett bdf6566655 Update src/core/webview/ClineProvider.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-27 17:26:27 -08:00
Dennis Bartlett 2bbd6e0307 Refactor CSP building for Prod and Dev 2025-02-25 19:10:20 -06:00
Dennis Bartlett 37b4dc477c Add 'Add Memory Bank' button in settings 2025-02-25 19:10:12 -06:00
canvrno 9d49e95295 Check git out - Checkpoints 2.0
**Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
-   **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.

-   **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
-   **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.

No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
2025-02-25 16:51:38 -08:00
Trevor Hudson 221b083588 add optional telemetry (#1939)
* add optional telemetry

* move capture

* add changeset

* implement suggestions
2025-02-25 16:43:15 -08:00
brownrw8 d7e73b4ada feat: Set preferred language in settings and have it update system prompt (#1538)
* feat: set preferred language in settings and have it update system prompt

* Update webview-ui/src/components/settings/PreferredLanguagePicker.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* remove unnecessary useEffect

* tweak prompt

* formatting from main

* Update Cline.ts

* Revert "Update Cline.ts"

This reverts commit bee6f0fee0.

* Fixes

* Update Cline.ts

* move preferredLanguage to Advanced Settings

* remove unneccessary import

* remove more imports

* skip language prompt for default language `en`

* Update package.json

* lint

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-02-25 16:35:32 -08:00
Frostbourne 3299201cec [ENG-126] Migrate to Vite (#1876)
* Initial webview vite migration

* Make vite work

* Fix test running

* Enable HMR, disable vite chunking

* Silence type checking errors

* Vite doesn't use browserslist

* get rid of breaking css flag

* add doc to getHMRHtmlContent

* Make it work

* Changeset

* Add IS_DEV to env definitions

* Update tasks to include HMR

* Update CSP image rules

* prettier

* reintroduce IS_DEV in env

* add new deps to pkg lock

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-02-25 16:18:17 -08:00
akfoster d88aab6090 Add .clineignore to .gitignore (#1966)
* clineignor should be ignored by git

* changeset
2025-02-25 16:17:31 -08:00
Andrei Edell 8ae5ee6bf3 Hugelung/rich mcp response (#1941)
* showing images after mcp responses

* images now open in a webview tab

* Open Graph link metadata display for MCP responses

* almost totally working rich mcp response display with images and embeds

* closer

* header for response display

* updated styling of mcp responses

* default to plain text if rich response is loading

* formatting fix

* added changeset output

* remove some old code

* add the dashed border back

* avoid XSS attacks by sanitizing the preview image urls and embeds

* remove incorrect vendor prefix css

* delete old version of open image implementation

* undo some comment removals and cleanups to make PR easier to read

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-02-25 12:39:14 -08:00
68 changed files with 5490 additions and 16639 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add "Add Memory Bank" button in settings
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
.clineignore should be included in .gitignore
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Migrate webview from CRA to Vite
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Include optional telemetry to help cline improve
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add rich MCP responses with images and link previews
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add preferred language option to settings
+11
View File
@@ -0,0 +1,11 @@
---
"claude-dev": minor
---
- **Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
- **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.
- **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
- **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.
No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
-3
View File
@@ -28,10 +28,7 @@ updates:
patterns:
- "*"
ignore:
# Ignore CRA and related packages that often have false positives
- dependency-name: "react-scripts"
- dependency-name: "@testing-library/*"
- dependency-name: "web-vitals"
- dependency-name: "*"
update-types:
- "version-update:semver-major"
+3 -1
View File
@@ -7,4 +7,6 @@ tmp
.DS_Store
pnpm-lock.yaml
pnpm-lock.yaml
.clineignore
+54 -4
View File
@@ -5,7 +5,7 @@
"tasks": [
{
"label": "watch",
"dependsOn": ["npm: build:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"dependsOn": ["npm: build:webview", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"presentation": {
"reveal": "never"
},
@@ -23,7 +23,42 @@
"label": "npm: build:webview",
"presentation": {
"group": "watch",
"reveal": "never"
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true"
}
}
},
{
"type": "npm",
"script": "dev:webview",
"group": "build",
"problemMatcher": [
{
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
}
}
],
"isBackground": true,
"label": "npm: dev:webview",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
@@ -40,7 +75,8 @@
"label": "npm: watch:esbuild",
"presentation": {
"group": "watch",
"reveal": "never"
"reveal": "never",
"close": true
}
},
{
@@ -52,7 +88,8 @@
"label": "npm: watch:tsc",
"presentation": {
"group": "watch",
"reveal": "never"
"reveal": "never",
"close": true
}
},
{
@@ -70,6 +107,19 @@
"label": "tasks: watch-tests",
"dependsOn": ["npm: watch", "npm: watch-tests"],
"problemMatcher": []
},
{
"label": "stop",
"command": "echo ${input:terminate}",
"type": "shell"
}
],
"inputs": [
{
"id": "terminate",
"type": "command",
"command": "workbench.action.tasks.terminate",
"args": "terminateAll"
}
]
}
-1
View File
@@ -23,7 +23,6 @@ demo.gif
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
webview-ui/src/**
webview-ui/public/**
webview-ui/scripts/**
webview-ui/index.html
webview-ui/README.md
webview-ui/package.json
+2 -1
View File
@@ -9,6 +9,7 @@ Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individ
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
- All processing happens locally on your machine
- API keys are stored securely in VS Code's built-in settings storage
- Telemetry is collected anonymously via PostHog if the user opts in
## Information We Process
@@ -43,7 +44,7 @@ Cline functions solely as a client-side VS Code extension that facilitates commu
- All operations happen on your local machine
- No central servers or data collection
- No telemetry or usage statistics gathered
- No telemetry or usage statistics gathered unless the user opts in
- No account creation required
2. **API Key Security**:
+37 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.4.6",
"version": "3.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.4.6",
"version": "3.4.8",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
@@ -36,10 +36,12 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.7.0",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
@@ -11047,6 +11049,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open-graph-scraper": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.9.0.tgz",
"integrity": "sha512-1KoV5v6GT0/MqlryrVGQROhEAD4u8wC3VjYOxsnhj3mWeGJ6N6nF/rbrcZREFr+kiYm9I5LMrzdK9t9hBMbL2Q==",
"license": "MIT",
"dependencies": {
"chardet": "^2.0.0",
"cheerio": "^1.0.0-rc.12",
"iconv-lite": "^0.6.3",
"undici": "^6.21.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/open-graph-scraper/node_modules/chardet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.0.0.tgz",
"integrity": "sha512-xVgPpulCooDjY6zH4m9YW3jbkaBe3FKIAvF5sj5t7aBNsVl2ljIE+xwJ4iNgiDZHFQvNIpjdKdVOQvvk5ZfxbQ==",
"license": "MIT"
},
"node_modules/openai": {
"version": "4.83.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.83.0.tgz",
@@ -11606,6 +11629,18 @@
"node": ">= 0.4"
}
},
"node_modules/posthog-node": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.7.0.tgz",
"integrity": "sha512-RgdUKSW8MfMOkjUa8cYVqWndNjPePNuuxlGbrZC6z1WRBsVc6TdGl8caidmC10RW8mu/BOfmrGbP4cRTo2jARg==",
"license": "MIT",
"dependencies": {
"axios": "^1.7.4"
},
"engines": {
"node": ">=15.0.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+33 -1
View File
@@ -182,10 +182,40 @@
"default": null,
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
},
"cline.preferredLanguage": {
"type": "string",
"enum": [
"English",
"Arabic - العربية",
"Portuguese - Português (Brasil)",
"Czech - Čeština",
"French - Français",
"German - Deutsch",
"Hindi - हिन्दी",
"Hungarian - Magyar",
"Italian - Italiano",
"Japanese - 日本語",
"Korean - 한국어",
"Polish - Polski",
"Portuguese - Português (Portugal)",
"Russian - Русский",
"Simplified Chinese - 简体中文",
"Spanish - Español",
"Traditional Chinese - 繁體中文",
"Turkish - Türkçe"
],
"default": "English",
"description": "The language that Cline should use for communication."
},
"cline.mcpMarketplace.enabled": {
"type": "boolean",
"default": true,
"description": "Controls whether the MCP Marketplace is enabled."
},
"cline.enableTelemetry": {
"type": "boolean",
"default": false,
"description": "Enables extension to post anonymous error tracking & usage data to help improve the product."
}
}
}
@@ -206,7 +236,7 @@
"format:fix": "prettier . --write",
"test": "vscode-test",
"install:all": "npm install && cd webview-ui && npm install",
"start:webview": "cd webview-ui && npm run start",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"publish:marketplace": "vsce publish && ovsx publish",
@@ -264,10 +294,12 @@
"isbinaryfile": "^5.0.2",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.7.0",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
+70 -7
View File
@@ -59,6 +59,8 @@ import { formatResponse } from "./prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
import posthog from "../services/analytics/PostHogClient"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -75,6 +77,7 @@ export class Cline {
browserSession: BrowserSession
private didEditFile: boolean = false
customInstructions?: string
preferredLanguage?: LanguageKey
autoApprovalSettings: AutoApprovalSettings
private browserSettings: BrowserSettings
private chatSettings: ChatSettings
@@ -135,6 +138,9 @@ export class Cline {
this.browserSession = new BrowserSession(provider.context, browserSettings)
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
this.preferredLanguage = getLanguageKey(
vscode.workspace.getConfiguration("cline").get<LanguageDisplay>("preferredLanguage"),
)
this.autoApprovalSettings = autoApprovalSettings
this.browserSettings = browserSettings
this.chatSettings = chatSettings
@@ -148,6 +154,16 @@ export class Cline {
} else {
throw new Error("Either historyItem or task/images must be provided")
}
// capture start of thread with the state at the beginning
posthog.capture({
event: "cline created",
properties: {
taskId: this.taskId,
isHistory: !!historyItem,
chatMode: this.chatSettings.mode,
hasImages: !!images,
},
})
}
updateBrowserSettings(browserSettings: BrowserSettings) {
@@ -285,7 +301,10 @@ export class Cline {
case "workspace":
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -398,7 +417,10 @@ export class Cline {
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -502,7 +524,10 @@ export class Cline {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -1269,6 +1294,10 @@ export class Cline {
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, mcpHub, this.browserSettings)
let settingsCustomInstructions = this.customInstructions?.trim()
const preferredLanguageInstructions =
this.preferredLanguage && this.preferredLanguage !== DEFAULT_LANGUAGE_SETTINGS
? `# Preferred Language\n\nSpeak in ${this.preferredLanguage}.`
: ""
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
if (await fileExistsAtPath(clineRulesFilePath)) {
@@ -1288,9 +1317,19 @@ export class Cline {
clineIgnoreInstructions = `# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${clineIgnoreContent}\n.clineignore`
}
if (settingsCustomInstructions || clineRulesFileInstructions) {
if (
settingsCustomInstructions ||
clineRulesFileInstructions ||
preferredLanguageInstructions ||
clineIgnoreInstructions
) {
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions, clineIgnoreInstructions)
systemPrompt += addUserInstructions(
settingsCustomInstructions,
clineRulesFileInstructions,
clineIgnoreInstructions,
preferredLanguageInstructions,
)
}
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
@@ -2910,7 +2949,7 @@ export class Cline {
}
/*
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
*/
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
@@ -3017,7 +3056,10 @@ export class Cline {
// isNewTask &&
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -3265,6 +3307,27 @@ export class Cline {
this.consecutiveMistakeCount++
}
posthog.capture({
event: "message sent",
properties: {
taskId: this.taskId,
chatMode: this.chatSettings.mode,
apiConversationHistoryCount: this.apiConversationHistory.length,
userMessageCount: this.apiConversationHistory.filter((m) => m.role === "user").length,
assistantMessageCount: this.apiConversationHistory.filter((m) => m.role === "assistant").length,
clineMessageCount: this.clineMessages.length,
textMessageCount: this.clineMessages.filter((m) => !!m.text).length,
askMessageCount: this.clineMessages.filter((m) => !!m.ask).length,
sayMessageCount: this.clineMessages.filter((m) => !!m.say).length,
reasoningMessageCount: this.clineMessages.filter((m) => !!m.reasoning).length,
partialMessageCount: this.clineMessages.filter((m) => !!m.partial).length,
consecutiveMistakeCount: this.consecutiveMistakeCount,
consecutiveAutoApprovedRequestsCount: this.consecutiveAutoApprovedRequestsCount,
toolUseCount: this.clineMessages.filter((m) => m.say === "tool").length,
checkpointsCount: this.clineMessages.filter((m) => m.say === "checkpoint_created").length,
},
})
const recDidEndLoop = await this.recursivelyMakeClineRequests(this.userMessageContent)
didEndLoop = recDidEndLoop
} else {
+4
View File
@@ -979,8 +979,12 @@ export function addUserInstructions(
settingsCustomInstructions?: string,
clineRulesFileInstructions?: string,
clineIgnoreInstructions?: string,
preferredLanguageInstructions?: string,
) {
let customInstructions = ""
if (preferredLanguageInstructions) {
customInstructions += preferredLanguageInstructions + "\n\n"
}
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
+193 -23
View File
@@ -8,8 +8,10 @@ import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { buildApiHandler } from "../../api"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
@@ -94,6 +96,7 @@ type GlobalStateKey =
| "requestyModelId"
| "togetherModelId"
| "mcpMarketplaceCatalog"
| "hideTelemetryOptIn"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@@ -126,6 +129,28 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.authManager = new FirebaseAuthManager(this)
}
/* Utility function to build CSP rules both non-dev and dev environments.
*/
buildCSP(webview: vscode.Webview, nonce: string) {
if (this.context.extensionMode === vscode.ExtensionMode.Development) {
return `default-src http: https: ws: data: 'unsafe-inline' 'unsafe-eval' 'nonce-${nonce}'`
}
const clineMemoryBankUrl =
"https://raw.githubusercontent.com/cline/cline/refs/heads/main/docs/prompting/custom%20instructions%20library/raw-instructions/cline-memory-bank.md"
let csp = ""
csp += `default-src 'none';`
csp += `connect-src ${clineMemoryBankUrl};`
csp += `font-src ${webview.cspSource};`
csp += `style-src ${webview.cspSource} 'unsafe-inline';`
csp += `img-src ${webview.cspSource} https: data:;`
csp += `script-src 'nonce-${nonce}';`
return csp
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
@@ -176,11 +201,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
}
resolveWebviewView(
webviewView: vscode.WebviewView | vscode.WebviewPanel,
//context: vscode.WebviewViewResolveContext<unknown>, used to recreate a deallocated webview, but we don't need this since we use retainContextWhenHidden
//token: vscode.CancellationToken
): void | Thenable<void> {
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
@@ -189,7 +210,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
webviewView.webview.html = this.getHtmlContent(webviewView.webview)
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is received
@@ -320,9 +345,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "css", "main.css"])
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "js", "main.js"])
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
@@ -365,20 +390,86 @@ export class ClineProvider implements vscode.WebviewViewProvider {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}';">
<meta http-equiv="Content-Security-Policy" content="${this.buildCSP(webview, nonce)}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src https://*.posthog.com; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' https://*.posthog.com;">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
const localPort = 25463
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
vscode.window.showErrorMessage(
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
)
return this.getHtmlContent(webview)
}
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${this.buildCSP(webview, nonce)}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Roo Code</title>
</head>
<body>
<div id="root"></div>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
@@ -635,6 +726,17 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "openImage":
openImage(message.text!)
break
case "openInBrowser":
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
case "fetchOpenGraphData":
this.fetchOpenGraphData(message.text!)
break
case "checkIsImageUrl":
this.checkIsImageUrl(message.text!)
break
case "openFile":
openFile(message.text!)
break
@@ -829,6 +931,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
)
break
}
case "toggleTelemetryOptIn": {
await vscode.workspace.getConfiguration().update("cline.enableTelemetry", message.bool, true)
await this.updateGlobalState("hideTelemetryOptIn", true)
await this.postStateToWebview()
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
@@ -1531,12 +1639,29 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
}
async deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
if (id === this.cline?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
// Delete checkpoints
// deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly
console.info("deleting checkpoints")
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = taskHistory.find((item) => item.id === id)
//console.log("historyItem: ", historyItem)
if (historyItem) {
try {
await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
} catch (error) {
console.error(`Failed to delete checkpoints for task ${id}:`, error)
}
}
await this.deleteTaskFromState(id)
// Delete the task files
@@ -1553,21 +1678,12 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
await fs.unlink(legacyMessagesFilePath)
}
// Delete the checkpoints directory if it exists
const checkpointsDir = path.join(taskDirPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
try {
await fs.rm(checkpointsDir, { recursive: true, force: true })
} catch (error) {
console.error(`Failed to delete checkpoints directory for task ${id}:`, error)
// Continue with deletion of task directory - don't throw since this is a cleanup operation
}
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
}
async deleteTaskFromState(id: string) {
console.log("deleteTaskFromState: ", id)
// Remove the task from history
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
@@ -1594,6 +1710,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
userInfo,
authToken,
mcpMarketplaceEnabled,
hideTelemetryOptIn,
} = await this.getState()
return {
@@ -1611,8 +1728,11 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
browserSettings,
chatSettings,
isLoggedIn: !!authToken,
advancedSettings: vscode.workspace.getConfiguration("cline"),
vscMachineId: vscode.env.machineId,
userInfo,
mcpMarketplaceEnabled,
hideTelemetryOptIn: hideTelemetryOptIn ?? false,
}
}
@@ -1635,7 +1755,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
/*
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
VSCode docs about state: "The value must be JSON-stringifyable ... value A value. MUST not contain cyclic references."
VSCode docs about state: "The value must be JSON-stringifyable ... value  A value. MUST not contain cyclic references."
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
*/
@@ -1719,6 +1839,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeModelInfo,
qwenApiLine,
liteLlmApiKey,
hideTelemetryOptIn,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1770,6 +1891,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
this.getGlobalState("hideTelemetryOptIn") as Promise<boolean | undefined>,
])
let apiProvider: ApiProvider
@@ -1847,6 +1969,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeModelId,
previousModeModelInfo,
mcpMarketplaceEnabled,
hideTelemetryOptIn,
}
}
@@ -1906,6 +2029,53 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return await this.context.secrets.get(key)
}
// Open Graph Data
async fetchOpenGraphData(url: string) {
try {
// Use the fetchOpenGraphData function from link-preview.ts
const ogData = await fetchOpenGraphData(url)
// Send the data back to the webview
await this.postMessageToWebview({
type: "openGraphData",
openGraphData: ogData,
url: url,
})
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Send an error response
await this.postMessageToWebview({
type: "openGraphData",
error: `Failed to fetch Open Graph data: ${error}`,
url: url,
})
}
}
// Check if a URL is an image
async checkIsImageUrl(url: string) {
try {
// Check if the URL is an image
const isImage = await isImageUrl(url)
// Send the result back to the webview
await this.postMessageToWebview({
type: "isImageUrlResult",
isImage,
url,
})
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// Send an error response
await this.postMessageToWebview({
type: "isImageUrlResult",
isImage: false,
url,
})
}
}
// dev
async resetState() {
+19 -1
View File
@@ -8,6 +8,7 @@ import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import posthog from "./services/analytics/PostHogClient"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -58,6 +59,22 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration("cline")) {
Logger.log("Configuration changed")
await sidebarProvider.postStateToWebview()
const config = vscode.workspace.getConfiguration("cline")
// we use optIn and optOut because we want to keep posthog active for feature flags
if (config.get("enableTelemetry")) {
posthog.optIn()
} else {
posthog.optOut()
}
}
}),
)
const openClineInNewTab = async () => {
Logger.log("Opening Cline in new tab")
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
@@ -188,8 +205,9 @@ export function activate(context: vscode.ExtensionContext) {
}
// This method is called when your extension is deactivated
export function deactivate() {
export async function deactivate() {
Logger.log("Cline extension deactivated")
await posthog.shutdown()
}
// TODO: Find a solution for automatically removing DEV related content from production builds.
@@ -0,0 +1,73 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import path from "path"
import os from "os"
import CheckpointTracker from "./CheckpointTracker"
export async function createTestEnvironment() {
// Create temp directory structure
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
await fs.mkdir(tempDir, { recursive: true })
// Create storage path outside of working directory to avoid submodule issues
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
await fs.mkdir(globalStoragePath, { recursive: true })
// Create test file in a subdirectory
const testDir = path.join(tempDir, "src")
await fs.mkdir(testDir, { recursive: true })
const testFilePath = path.join(testDir, "test.txt")
// Create .gitignore to prevent git from treating directories as submodules
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
// Mock VS Code workspace
const mockWorkspaceFolders = [
{
uri: { fsPath: tempDir },
name: "test",
index: 0,
},
]
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => mockWorkspaceFolders,
})
// Mock findFiles to return no nested git repos
const originalFindFiles = vscode.workspace.findFiles
vscode.workspace.findFiles = async () => []
// Mock VS Code configuration
const originalGetConfiguration = vscode.workspace.getConfiguration
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
return {
tempDir,
globalStoragePath,
testFilePath,
originalDescriptor,
originalFindFiles,
originalGetConfiguration,
cleanup: async () => {
// Restore VS Code mocks
if (originalDescriptor) {
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
}
vscode.workspace.getConfiguration = originalGetConfiguration
vscode.workspace.findFiles = originalFindFiles
// Clean up temp directories
await fs.rm(tempDir, { recursive: true, force: true })
await fs.rm(globalStoragePath, { recursive: true, force: true })
}
}
}
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
return await CheckpointTracker.create(taskId, globalStoragePath)
}
@@ -0,0 +1,153 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Commit Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create commit with single file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify commits are different
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should create commit with multiple file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial files with newlines
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial\n")
await fs.writeFile(testFile2Path, "file2 initial\n")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify both files with newlines
await fs.writeFile(env.testFilePath, "file1 modified\n")
await fs.writeFile(testFile2Path, "file2 modified\n")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(2)
// Sort diffSet by path for consistent ordering
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
// Verify file paths
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
// Verify file contents
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
})
it("should create commit when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Delete file
await fs.unlink(env.testFilePath)
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify file deletion was committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("")
})
it("should create empty commit when no changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "test content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Create commit without changes
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify no changes between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(0)
})
it("should handle files in nested directories", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create nested directory structure
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
await fs.mkdir(nestedDir, { recursive: true })
const nestedFilePath = path.join(nestedDir, "nested.txt")
// Create and commit file in nested directory
await fs.writeFile(nestedFilePath, "nested content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify nested file
await fs.writeFile(nestedFilePath, "modified nested content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
// Verify changes were committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
expect(diffSet[0].before).to.equal("nested content")
expect(diffSet[0].after).to.equal("modified nested content")
})
})
@@ -0,0 +1,35 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Creation", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create a new checkpoint tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
expect(tracker).to.be.instanceOf(CheckpointTracker)
// Verify shadow git config
const configWorkTree = await tracker?.getShadowGitConfigWorkTree()
expect(configWorkTree).to.not.be.undefined
})
it("should throw error when globalStoragePath is missing", async () => {
try {
await createTestTracker(undefined)
expect.fail("Expected error was not thrown")
} catch (error: any) {
expect(error.message).to.equal("Global storage path is required to create a checkpoint tracker")
}
})
})
@@ -0,0 +1,68 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Diff Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should detect file changes between commits", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first checkpoint
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second checkpoint
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should detect changes between commit and working directory", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create checkpoint
const commit = await tracker.commit()
expect(commit).to.not.be.undefined
// Modify file without committing
await fs.writeFile(env.testFilePath, "working directory changes")
// Get diff between commit and working directory
const diffSet = await tracker.getDiffSet(commit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("working directory changes")
})
})
@@ -0,0 +1,94 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import * as vscode from "vscode"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Disabled State", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let originalGetConfiguration: typeof vscode.workspace.getConfiguration
beforeEach(async () => {
env = await createTestEnvironment()
originalGetConfiguration = vscode.workspace.getConfiguration
// Mock VS Code configuration to disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
})
afterEach(async () => {
await env.cleanup()
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should return undefined when creating tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.be.undefined
})
it("should allow re-enabling checkpoints", async () => {
// First verify disabled state
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Mock configuration to enable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Verify tracker can be created when enabled
const enabledTracker = await createTestTracker(env.globalStoragePath)
expect(enabledTracker).to.not.be.undefined
// Verify operations work
if (!enabledTracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "test content")
const commit = await enabledTracker.commit()
expect(commit).to.be.a("string").and.not.empty
})
it("should prevent operations when disabled mid-session", async () => {
// Start with checkpoints enabled
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Create tracker and initial commit
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
if (!tracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
// Verify new tracker cannot be created
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Verify existing tracker still works
// This is expected behavior since the tracker was created when enabled
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify diffs still work on existing tracker
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
})
@@ -0,0 +1,335 @@
import fs from "fs/promises"
import { join } from "path"
import { fileExistsAtPath } from "../../utils/fs"
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
/**
* CheckpointExclusions Module
*
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
* for the checkpoint tracking process. It provides:
*
* File Filtering:
* - File types (build artifacts, media, cache files, etc.)
* - Git LFS patterns from workspace
* - Environment and configuration files
* - Temporary and cache files
*
* Pattern Management:
* - Extensible category-based pattern system
* - Comprehensive file type coverage
* - Easy pattern updates and maintenance
*
* Git Integration:
* - Seamless integration with Git's exclude mechanism
* - Support for workspace-specific LFS patterns
* - Automatic pattern updates during checkpoints
*
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
* of large files, binary files, and temporary artifacts while maintaining a clean
* and organized checkpoint history.
*/
/**
* Interface representing the result of a file exclusion check
*/
interface ExclusionResult {
/** Whether the file should be excluded */
excluded: boolean
/** Optional reason for exclusion */
reason?: string
}
/**
* Returns the default list of file and directory patterns to exclude from checkpoints.
* Combines built-in patterns with workspace-specific LFS patterns.
*
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
* @returns Array of glob patterns to exclude
* @todo Make this configurable by the user
*/
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
// Build and Development Artifacts
".git/",
`.git${GIT_DISABLED_SUFFIX}/`,
...getBuildArtifactPatterns(),
// Media Files
...getMediaFilePatterns(),
// Cache and Temporary Files
...getCacheFilePatterns(),
// Environment and Config Files
...getConfigFilePatterns(),
// Large Data Files
...getLargeDataFilePatterns(),
// Database Files
...getDatabaseFilePatterns(),
// Geospatial Datasets
...getGeospatialPatterns(),
// Log Files
...getLogFilePatterns(),
...lfsPatterns,
]
/**
* Returns patterns for common build and development artifact directories
* @returns Array of glob patterns for build artifacts
*/
function getBuildArtifactPatterns(): string[] {
return [
".gradle/",
".idea/",
".parcel-cache/",
".pytest_cache/",
".next/",
".nuxt/",
".sass-cache/",
".vs/",
".vscode/",
"Pods/",
"__pycache__/",
"bin/",
"build/",
"bundle/",
"coverage/",
"deps/",
"dist/",
"env/",
"node_modules/",
"obj/",
"out/",
"pkg/",
"pycache/",
"target/dependency/",
"temp/",
"vendor/",
"venv/",
]
}
/**
* Returns patterns for common media and image file types
* @returns Array of glob patterns for media files
*/
function getMediaFilePatterns(): string[] {
return [
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
"*.webp",
"*.tiff",
"*.tif",
"*.svg",
"*.raw",
"*.heic",
"*.avif",
"*.eps",
"*.psd",
"*.3gp",
"*.aac",
"*.aiff",
"*.asf",
"*.avi",
"*.divx",
"*.flac",
"*.m4a",
"*.m4v",
"*.mkv",
"*.mov",
"*.mp3",
"*.mp4",
"*.mpeg",
"*.mpg",
"*.ogg",
"*.opus",
"*.rm",
"*.rmvb",
"*.vob",
"*.wav",
"*.webm",
"*.wma",
"*.wmv",
]
}
/**
* Returns patterns for cache, temporary, and system files
* @returns Array of glob patterns for cache files
*/
function getCacheFilePatterns(): string[] {
return [
"*.DS_Store",
"*.bak",
"*.cache",
"*.crdownload",
"*.dmp",
"*.dump",
"*.eslintcache",
"*.lock",
"*.log",
"*.old",
"*.part",
"*.partial",
"*.pyc",
"*.pyo",
"*.stackdump",
"*.swo",
"*.swp",
"*.temp",
"*.tmp",
"*.Thumbs.db",
]
}
/**
* Returns patterns for environment and configuration files
* @returns Array of glob patterns for config files
*/
function getConfigFilePatterns(): string[] {
return ["*.env*", "*.local", "*.development", "*.production"]
}
/**
* Returns patterns for common large binary and archive files
* @returns Array of glob patterns for large data files
*/
function getLargeDataFilePatterns(): string[] {
return [
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
"*.dat",
"*.dmg",
"*.msi",
]
}
/**
* Returns patterns for database and data storage files
* @returns Array of glob patterns for database files
*/
function getDatabaseFilePatterns(): string[] {
return [
"*.arrow",
"*.accdb",
"*.aof",
"*.avro",
"*.bak",
"*.bson",
"*.csv",
"*.db",
"*.dbf",
"*.dmp",
"*.frm",
"*.ibd",
"*.mdb",
"*.myd",
"*.myi",
"*.orc",
"*.parquet",
"*.pdb",
"*.rdb",
"*.sql",
"*.sqlite",
]
}
/**
* Returns patterns for geospatial and mapping data files
* @returns Array of glob patterns for geospatial files
*/
function getGeospatialPatterns(): string[] {
return [
"*.shp",
"*.shx",
"*.dbf",
"*.prj",
"*.sbn",
"*.sbx",
"*.shp.xml",
"*.cpg",
"*.gdb",
"*.mdb",
"*.gpkg",
"*.kml",
"*.kmz",
"*.gml",
"*.geojson",
"*.dem",
"*.asc",
"*.img",
"*.ecw",
"*.las",
"*.laz",
"*.mxd",
"*.qgs",
"*.grd",
"*.csv",
"*.dwg",
"*.dxf",
]
}
/**
* Returns patterns for log and debug output files
* @returns Array of glob patterns for log files
*/
function getLogFilePatterns(): string[] {
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
}
/**
* Writes the combined exclusion patterns to Git's exclude file.
* Creates the info directory if it doesn't exist.
*
* @param gitPath - Path to the .git directory
* @param lfsPatterns - Optional array of Git LFS patterns to include
*/
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
const excludesPath = join(gitPath, "info", "exclude")
await fs.mkdir(join(gitPath, "info"), { recursive: true })
const patterns = getDefaultExclusions(lfsPatterns)
await fs.writeFile(excludesPath, patterns.join("\n"))
}
/**
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
* Returns an empty array if no patterns found or file doesn't exist.
*
* @param workspacePath - Path to the workspace root
* @returns Array of Git LFS patterns found in .gitattributes
*/
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
try {
const attributesPath = join(workspacePath, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
return attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
return []
}
@@ -0,0 +1,482 @@
import simpleGit, { SimpleGit } from "simple-git"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import fs from "fs/promises"
import * as path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import * as vscode from "vscode"
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
import { HistoryItem } from "../../shared/HistoryItem"
interface StorageProvider {
context: {
globalStorageUri: { fsPath: string }
}
}
interface CheckpointAddResult {
success: boolean
fileCount: number
}
/**
* GitOperations Class
*
* Handles git-specific operations for Cline's Checkpoints system.
*
* Key responsibilities:
* - Git repository initialization and configuration
* - Git settings management (user, LFS, etc.)
* - Worktree configuration and management
* - Task-specific branch management (creation, switching, deletion)
* - Handling of both legacy and branch-per-task checkpoint structures
* - Managing nested git repositories during checkpoint operations
* - File staging and checkpoint creation
* - Shadow git repository maintenance and cleanup
*/
export class GitOperations {
private cwd: string
private isLegacyCheckpoint: boolean
/**
* Creates a new GitOperations instance.
*
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
*/
constructor(cwd: string, isLegacyCheckpoint: boolean) {
this.cwd = cwd
this.isLegacyCheckpoint = isLegacyCheckpoint
}
/**
* Initializes or verifies a shadow Git repository for checkpoint tracking.
* Creates a new repository if one doesn't exist, or verifies the worktree
* configuration if it does.
*
* Key operations:
* - Creates/verifies shadow git repository
* - Configures git settings (user, LFS, etc.)
* - Sets up worktree to point to workspace
* - Creates initial empty commit
* - Handles both legacy and branch-per-task checkpoint structures
*
* @param gitPath - Path to the .git directory
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
* @returns Promise<string> Path to the initialized .git directory
* @throws Error if:
* - Worktree verification fails for existing repository
* - Git initialization or configuration fails
* - Unable to create initial commit
* - LFS pattern setup fails
*/
public static async initShadowGit(gitPath: string, cwd: string, isLegacyCheckpoint: boolean): Promise<string> {
console.info(`Initializing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git`)
// If repo exists, just verify worktree
if (await fileExistsAtPath(gitPath)) {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
if (worktree.value !== cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
}
console.warn(`Using existing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git at ${gitPath}`)
return gitPath
}
// Initialize new repo
const checkpointsDir = path.dirname(gitPath)
console.warn(`Creating new ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git in ${checkpointsDir}`)
const git = simpleGit(checkpointsDir)
await git.init()
// Configure repo
await git.addConfig("core.worktree", cwd)
await git.addConfig("commit.gpgSign", "false")
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "checkpoint@cline.bot")
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
// Set up LFS patterns
const lfsPatterns = await getLfsPatterns(cwd)
await writeExcludesFile(gitPath, lfsPatterns)
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
console.warn(`${isLegacyCheckpoint ? "Legacy" : "New"} shadow git initialization completed`)
return gitPath
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* @param gitPath - Path to the .git directory
* @returns Promise<string | undefined> The worktree path or undefined if not found
* @throws Error if unable to get worktree path
*/
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
try {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
return worktree.value || undefined
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
return undefined
}
}
/**
* Checks if a shadow Git repository exists for the given task and workspace.
* Checks both legacy checkpoint paths (tasks/{taskId}/checkpoints/.git) and
* branch-per-task paths (checkpoints/{workspaceHash}/.git).
*
* @param taskId - The ID of the task whose shadow git to check
* @param provider - The ClineProvider instance for accessing VS Code functionality
* @returns Promise<boolean> True if either a legacy or branch-per-task shadow git exists, false otherwise
*/
public static async doesShadowGitExist(taskId: string, provider?: StorageProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
// Check legacy checkpoint path to see if this is a legacy task
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy shadow git")
return true
}
// Check branch-per-task path for newer tasks
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
const exists = await fileExistsAtPath(gitPath)
if (exists) {
console.info("Found branch-per-task shadow git")
}
return exists
}
/**
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
* If the branch to be deleted is currently checked out, the method will:
* 1. Save the current worktree configuration
* 2. Temporarily unset the worktree to prevent workspace modifications
* 3. Force switch to master/main branch
* 4. Delete the target branch
* 5. Restore the worktree configuration
*
* @param git - SimpleGit instance to use for operations
* @param branchName - Name of the branch to delete
* @param checkpointsDir - Directory containing the git repository
* @throws Error if:
* - Branch deletion fails
* - Unable to switch to master/main branch after 3 retries
* - Git operations fail during the process
*/
public static async deleteBranchForGit(git: SimpleGit, branchName: string, checkpointsDir: string): Promise<void> {
// Check if branch exists
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
return // Branch doesn't exist, nothing to delete
}
// First, if we're on the branch to be deleted, switch to master/main
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
if (currentBranch === branchName) {
console.debug("Currently on branch to be deleted, switching to master/main first")
// Save the current worktree config
const worktree = await git.getConfig("core.worktree")
console.debug(`Saved current worktree config: ${worktree.value}`)
try {
// Temporarily unset worktree to prevent workspace modifications
console.debug("Temporarily unsetting worktree config")
await git.raw(["config", "--unset", "core.worktree"])
// Force discard all changes
console.debug("Discarding all changes")
await git.reset(["--hard"])
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
// Determine default branch (master or main)
const defaultBranch = branches.all.includes("main") ? "main" : "master"
console.debug(`Using ${defaultBranch} as default branch`)
// Switch to default branch and delete branch
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
await git.checkout([defaultBranch, "--force"])
// Verify the switch completed
let retries = 3
while (retries > 0) {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.debug(`Verifying branch switch - current branch: ${newBranch}, attempts left: ${retries}`)
if (newBranch === defaultBranch) {
console.debug(`Successfully switched to ${defaultBranch} branch`)
break
}
retries--
if (retries === 0) {
throw new Error(`Failed to switch to ${defaultBranch} branch`)
}
}
console.info(`Deleting branch: ${branchName}`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
} finally {
// Restore the worktree config
if (worktree.value) {
console.debug(`Restoring worktree config to: ${worktree.value}`)
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
// If we're not on the branch, we can safely delete it
console.info(`Directly deleting branch ${branchName} since we're not on it`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
}
}
/**
* Static method to delete a task's branch using stored workspace path.
* Handles both branch-per-task and legacy checkpoint formats:
* 1. First attempts to delete branch-per-task checkpoint if it exists
* 2. Falls back to deleting legacy checkpoint directory if found
*
* @param taskId - The ID of the task whose branch should be deleted
* @param historyItem - The history item containing the shadow git config
* @param globalStoragePath - Path to VS Code's global storage
* @throws Error if:
* - Global storage path is invalid
* - Branch deletion fails
* - Legacy checkpoint directory deletion fails
*/
public static async deleteTaskBranchStatic(
taskId: string,
historyItem: HistoryItem,
globalStoragePath: string,
): Promise<void> {
try {
console.debug("Starting static task branch deletion process...")
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
// First try to handle branch-per-task checkpoint
let workingDir: string
if (historyItem.shadowGitConfigWorkTree) {
workingDir = historyItem.shadowGitConfigWorkTree
} else {
// Try to determine working directory from current state
workingDir = await getWorkingDirectory()
}
const cwdHash = hashWorkingDir(workingDir)
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
const gitPath = path.join(checkpointsDir, ".git")
if (await fileExistsAtPath(gitPath)) {
console.debug(`Found branch-per-task git repository at ${gitPath}`)
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Check if the branch exists
const branches = await git.branchLocal()
if (branches.all.includes(branchName)) {
console.info(`Found branch ${branchName} to delete`)
await GitOperations.deleteBranchForGit(git, branchName, checkpointsDir)
return
}
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
}
// Only check legacy checkpoint if we didn't find/delete a branch-per-task branch
const legacyCheckpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
const legacyGitPath = path.join(legacyCheckpointsDir, ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy checkpoint, deleting directory")
try {
await fs.rm(legacyCheckpointsDir, { recursive: true, force: true })
console.debug("Successfully deleted legacy checkpoint directory")
return
} catch (error) {
console.error("Failed to delete legacy checkpoint directory:", error)
throw error
}
}
console.info("No checkpoints found to delete")
} catch (error) {
console.error("Failed to delete task branch:", error)
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
* requirement of using submodules for nested repos.
*
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
* only processes actual directories (not files named .git).
*
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
* @throws Error if renaming any .git directory fails
*/
public async renameNestedGitRepos(disable: boolean): Promise<void> {
// Find all .git directories that are not at the root level using VS Code API
const gitFiles = await vscode.workspace.findFiles(
new vscode.RelativePattern(this.cwd, "**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX)),
new vscode.RelativePattern(this.cwd, ".git/**"), // Exclude root .git
)
// Filter to only include directories
const gitPaths: string[] = []
for (const file of gitFiles) {
const relativePath = path.relative(this.cwd, file.fsPath)
try {
const stats = await fs.stat(path.join(this.cwd, relativePath))
if (stats.isDirectory()) {
gitPaths.push(relativePath)
}
} catch {
// Skip if stat fails
continue
}
}
// For each nested .git directory, rename it based on the disable flag
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.info(`${disable ? "Disabled" : "Enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
/**
* Switches to or creates a task-specific branch in the shadow Git repository.
* For legacy checkpoints, this is a no-op since they use separate repositories.
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
*
* The method performs the following:
* 1. Gets the shadow git path and initializes simple-git
* 2. Constructs the branch name using the task ID
* 3. Checks if the branch exists:
* - If not, creates a new branch
* - If yes, switches to the existing branch
* 4. Verifies the branch switch completed successfully
*
* Branch naming convention:
* task-{taskId}
*
* @param taskId - The ID of the task whose branch to switch to
* @param gitPath - Path to the .git directory
* @returns Promise<void>
* @throws Error if branch operations fail or git commands error
*/
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Create new task-specific branch, or switch to one if it already exists.
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.info(`Creating new task branch: ${branchName}`)
await git.checkoutLocalBranch(branchName)
} else {
console.info(`Switching to existing task branch: ${branchName}`)
await git.checkout(branchName)
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current Checkpoint branch after switch: ${currentBranch}`)
}
/**
* Adds files to the shadow git repository while handling nested git repos.
* Uses git commands to list files and stages them for commit.
* Respects .gitignore and handles LFS patterns.
*
* Process:
* 1. Updates exclude patterns from LFS config
* 2. Temporarily disables nested git repos
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
* 4. Adds all files to git staging
* 5. Re-enables nested git repos
*
* @param git - SimpleGit instance configured for the shadow git repo
* @param gitPath - Path to the .git directory
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
* @throws Error if:
* - File operations fail
* - Git commands error
* - LFS pattern updates fail
* - Nested git repo handling fails
*/
public async addCheckpointFiles(git: SimpleGit, gitPath: string): Promise<CheckpointAddResult> {
try {
// Update exclude patterns before each commit
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
await this.renameNestedGitRepos(true)
//console.info("Starting checkpoint add operation...")
// Get list of all files git would track (respects .gitignore)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const gitFiles = (await git.raw(["ls-files", "--others", "--exclude-standard", "--cached"]))
.split("\n")
.filter(Boolean)
// Add filtered files
if (gitFiles.length === 0) {
console.info("No files to add to checkpoint")
return { success: true, fileCount: 0 }
}
try {
console.info(`Adding ${gitFiles.length} files to checkpoint`)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
await git.add(gitFiles)
console.info("Checkpoint add operation completed successfully")
return { success: true, fileCount: gitFiles.length }
} catch (error) {
console.error("Checkpoint add operation failed:", error)
throw error
}
} catch (error) {
console.error("Failed to add files to checkpoint", error)
throw error
} finally {
await this.renameNestedGitRepos(false)
}
}
}
export const GIT_DISABLED_SUFFIX = "_disabled"
@@ -0,0 +1,95 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Revert Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should revert working directory to a previous checkpoint state", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Create and commit changes
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes without committing
await fs.writeFile(env.testFilePath, "uncommitted changes")
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file content matches initial state
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
it("should handle reverting with multiple files", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state with multiple files
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial")
await fs.writeFile(testFile2Path, "file2 initial")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify both files and commit
await fs.writeFile(env.testFilePath, "file1 modified")
await fs.writeFile(testFile2Path, "file2 modified")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes
await fs.writeFile(env.testFilePath, "file1 uncommitted")
await fs.writeFile(testFile2Path, "file2 uncommitted")
// Reset to first commit
await tracker.resetHead(firstCommit!)
// Verify both files match initial state
const file1Content = await fs.readFile(env.testFilePath, "utf8")
const file2Content = await fs.readFile(testFile2Path, "utf8")
expect(file1Content).to.equal("file1 initial")
expect(file2Content).to.equal("file2 initial")
})
it("should handle reverting when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Delete file and commit
await fs.unlink(env.testFilePath)
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file is restored with original content
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
})
@@ -0,0 +1,120 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import { HistoryItem } from "../../shared/HistoryItem"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Task Switching", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let taskId1: string
let taskId2: string
let tracker1: CheckpointTracker | undefined
let tracker2: CheckpointTracker | undefined
beforeEach(async () => {
env = await createTestEnvironment()
taskId1 = "task-1"
taskId2 = "task-2"
tracker1 = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1) {throw new Error("Failed to create tracker1")}
})
afterEach(async () => {
await env.cleanup()
})
it("should maintain separate history for each task", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 initial")
const task1Commit1 = await tracker1.commit()
expect(task1Commit1).to.be.a("string").and.not.empty
// Modify and commit again in first task
await fs.writeFile(env.testFilePath, "task1 modified")
const task1Commit2 = await tracker1.commit()
expect(task1Commit2).to.be.a("string").and.not.empty
// Create second task tracker
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
// Create and commit file in second task
await fs.writeFile(env.testFilePath, "task2 initial")
const task2Commit1 = await tracker2.commit()
expect(task2Commit1).to.be.a("string").and.not.empty
// Create another commit to establish history
await fs.writeFile(env.testFilePath, "task2 modified")
const task2Commit2 = await tracker2.commit()
expect(task2Commit2).to.be.a("string").and.not.empty
// Verify second task's history
const task2Diff = await tracker2.getDiffSet(task2Commit1, task2Commit2)
expect(task2Diff).to.have.lengthOf(1)
expect(task2Diff[0].before).to.equal("task2 initial")
expect(task2Diff[0].after).to.equal("task2 modified")
// Switch back to first task by creating new tracker
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
// Verify first task's history is preserved
const task1Diff = await tracker1Again.getDiffSet(task1Commit1, task1Commit2)
expect(task1Diff[0].before).to.equal("task1 initial")
expect(task1Diff[0].after).to.equal("task1 modified")
// Reset first task to initial state
if (!task1Commit1) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit1)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 initial")
})
it("should handle task deletion and recreation", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 content")
const task1Commit = await tracker1.commit()
expect(task1Commit).to.be.a("string").and.not.empty
// Create second task
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
await fs.writeFile(env.testFilePath, "task2 content")
const task2Commit = await tracker2.commit()
expect(task2Commit).to.be.a("string").and.not.empty
// Delete second task's checkpoints
const historyItem: HistoryItem = {
id: `test-${Date.now()}`,
ts: Date.now(),
task: taskId2,
shadowGitConfigWorkTree: env.tempDir,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
await CheckpointTracker.deleteCheckpoints(taskId2, historyItem, env.globalStoragePath)
// Recreate second task
const tracker2Again = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2Again) {throw new Error("Failed to create tracker2Again")}
// Create new commit in recreated task
await fs.writeFile(env.testFilePath, "task2 new content")
const newCommit = await tracker2Again.commit()
expect(newCommit).to.be.a("string").and.not.empty
// Switch back to first task and verify its history is intact
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
if (!task1Commit) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 content")
})
})
+331 -298
View File
@@ -1,31 +1,100 @@
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
import { HistoryItem } from "../../shared/HistoryItem"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils"
/**
* CheckpointTracker Module
*
* Core implementation of Cline's Checkpoints system that provides version control
* capabilities without interfering with the user's main Git repository. Key features:
*
* Shadow Git Repository:
* - Creates and manages an isolated Git repository for tracking checkpoints
* - Handles nested Git repositories by temporarily disabling them
* - Configures Git settings automatically (identity, LFS, etc.)
*
* File Management:
* - Integrates with CheckpointExclusions for file filtering
* - Handles workspace validation and path resolution
* - Manages Git worktree configuration
*
* Checkpoint Operations:
* - Creates checkpoints (commits) of the current state
* - Provides diff capabilities between checkpoints
* - Supports resetting to previous checkpoints
*
* Safety Features:
* - Prevents usage in sensitive directories (home, desktop, etc.)
* - Validates workspace configuration
* - Handles cleanup and resource disposal
*
* Checkpoint Architecture:
* - Uses a branch-per-task model to consolidate shadow git repositories
* - Each task gets its own branch within a single shadow git per workspace
* - Maintains backward compatibility with legacy checkpoint structure
* - Automatically cleans up by deleting task branches when tasks are removed
*/
class CheckpointTracker {
private providerRef: WeakRef<ClineProvider>
private globalStoragePath: string
private taskId: string
private disposables: vscode.Disposable[] = []
private cwd: string
private cwdHash: string
private lastRetrievedShadowGitConfigWorkTree?: string
lastCheckpointHash?: string
private lastCheckpointHash?: string
private isLegacyCheckpoint: boolean = false
private gitOperations: GitOperations
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
this.providerRef = new WeakRef(provider)
/**
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
* The constructor is private - use the static create() method to instantiate.
*
* @param taskId - Unique identifier for the task being tracked
* @param cwd - The current working directory to track files in
* @param cwdHash - Hash of the working directory path for shadow git organization
*/
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
this.globalStoragePath = globalStoragePath
this.taskId = taskId
this.cwd = cwd
this.cwdHash = cwdHash
this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode
}
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
/**
* Creates a new CheckpointTracker instance for tracking changes in a task.
* Handles initialization of the shadow git repository and branch setup.
*
* @param taskId - Unique identifier for the task to track
* @param globalStoragePath - the globalStorage path
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
* @throws Error if:
* - globalStoragePath is not supplied
* - Git is not installed
* - Working directory is invalid or in a protected location
* - Shadow git initialization fails
*
* Key operations:
* - Validates git installation and settings
* - Creates/initializes shadow git repository
* - Detects and handles legacy checkpoint structure
* - Sets up task-specific branch for new checkpoints
*
* Configuration:
* - Respects 'cline.enableCheckpoints' VS Code setting
* - Uses branch-per-task architecture for new checkpoints
* - Maintains backwards compatibility with legacy structure
*/
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
if (!globalStoragePath) {
throw new Error("Global storage path is required to create a checkpoint tracker")
}
try {
if (!provider) {
throw new Error("Provider is required to create a checkpoint tracker")
}
console.info(`Creating new CheckpointTracker for task ${taskId}`)
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
@@ -40,9 +109,36 @@ class CheckpointTracker {
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
}
const cwd = await CheckpointTracker.getWorkingDirectory()
const newTracker = new CheckpointTracker(provider, taskId, cwd)
await newTracker.initShadowGit()
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
// Check if this is a legacy task
newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId)
if (newTracker.isLegacyCheckpoint) {
console.debug("Using legacy checkpoint path structure")
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
}
// Branch-per-task structure
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
@@ -50,203 +146,110 @@ class CheckpointTracker {
}
}
private static async getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
/**
* Creates a new checkpoint commit in the shadow git repository.
*
* Key behaviors:
* - Creates commit with checkpoint files in shadow git repo
* - Handles both legacy and branch-per-task checkpoint structures
* - For new tasks, switches to task-specific branch first
* - Caches the created commit hash
*
* Commit structure:
* - Legacy: Simple "checkpoint" message
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
* - Always allows empty commits
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - For new checkpoints, requires task branch setup
* - Uses addCheckpointFiles to stage changes
*
* @returns Promise<string | undefined> The created commit hash, or undefined if:
* - Shadow git access fails
* - Branch switch fails
* - Staging files fails
* - Commit creation fails
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch branches
* - Stage or commit files
*/
public async commit(): Promise<string | undefined> {
try {
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
console.info(`Using shadow git at: ${gitPath}`)
private async getShadowGitPath(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
// Disable nested git repos before any operations
await this.gitOperations.renameNestedGitRepos(true)
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
return await fileExistsAtPath(gitPath)
}
public async initShadowGit(): Promise<string> {
const gitPath = await this.getShadowGitPath()
if (await fileExistsAtPath(gitPath)) {
// Make sure it's the same cwd as the configured worktree
const worktree = await this.getShadowGitConfigWorkTree()
if (worktree !== this.cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
}
return gitPath
} else {
const checkpointsDir = path.dirname(gitPath)
const git = simpleGit(checkpointsDir)
await git.init()
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
// Disable commit signing for shadow repo
await git.addConfig("commit.gpgSign", "false")
// Get LFS patterns from workspace if they exist
let lfsPatterns: string[] = []
try {
const attributesPath = path.join(this.cwd, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
lfsPatterns = attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
await this.gitOperations.addCheckpointFiles(git, gitPath)
const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId
console.info(
`Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`,
)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
console.warn(`Checkpoint commit created.`)
return commitHash
} finally {
// Always re-enable nested git repos
await this.gitOperations.renameNestedGitRepos(false)
}
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
// TODO: let user customize these
const excludesPath = path.join(gitPath, "info", "exclude")
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
await fs.writeFile(
excludesPath,
[
".git/", // ignore the user's .git
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
".DS_Store",
"*.log",
"node_modules/",
"__pycache__/",
"env/",
"venv/",
"target/dependency/",
"build/dependencies/",
"dist/",
"out/",
"bundle/",
"vendor/",
"tmp/",
"temp/",
"deps/",
"pkg/",
"Pods/",
// Media files
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
// "*.svg",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.mov",
"*.wmv",
"*.webm",
"*.webp",
"*.m4a",
"*.flac",
// Build and dependency directories
"build/",
"bin/",
"obj/",
".gradle/",
".idea/",
".vscode/",
".vs/",
"coverage/",
".next/",
".nuxt/",
// Cache and temporary files
"*.cache",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.pyc",
"*.pyo",
".pytest_cache/",
".eslintcache",
// Environment and config files
".env*",
"*.local",
"*.development",
"*.production",
// Large data files
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
// Database files
"*.sqlite",
"*.db",
"*.sql",
// Log files
"*.logs",
"*.error",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
...lfsPatterns,
].join("\n"),
)
// Set up git identity (git throws an error if user.name or user.email is not set)
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "noreply@example.com")
await this.addAllFiles(git)
// Initial commit (--allow-empty ensures it works even with no files)
await git.commit("initial commit", { "--allow-empty": null })
return gitPath
} catch (error) {
console.error("Failed to create checkpoint:", {
taskId: this.taskId,
error,
isLegacyCheckpoint: this.isLegacyCheckpoint,
})
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* Key behaviors:
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
* - Returns cached value if available
* - Reads git config if no cached value exists
* - Handles both legacy and new checkpoint structures
*
* Configuration read:
* - Uses simple-git to read core.worktree config
* - Operates on shadow git at path from getShadowGitPath()
*
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
* - Shadow git repository doesn't exist
* - Config read fails
* - No worktree is configured
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Read git configuration
*/
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
if (this.lastRetrievedShadowGitConfigWorkTree) {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
@@ -254,36 +257,32 @@ class CheckpointTracker {
}
}
public async commit(): Promise<string | undefined> {
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
await this.addAllFiles(git)
const result = await git.commit("checkpoint", {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", error)
return undefined
}
}
/**
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
* This will discard all changes after the target commit and restore the
* working directory to that checkpoint's state.
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - For new checkpoints, requires task branch setup
* - Must be called with a valid commit hash from this task's history
*
* @param commitHash - The hash of the checkpoint commit to reset to
* @returns Promise<void> Resolves when reset is complete
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch to task branch
* - Reset to target commit
*/
public async resetHead(commitHash: string): Promise<void> {
const gitPath = await this.getShadowGitPath()
console.info(`Resetting to checkpoint: ${commitHash}`)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
// Clean working directory and force reset
// This ensures that the operation will succeed regardless of:
// - Untracked files in the workspace
// - Staged changes
// - Unstaged changes
// - Partial commits
// - Merge conflicts
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
console.debug(`Using shadow git at: ${gitPath}`)
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
}
/**
@@ -310,111 +309,145 @@ class CheckpointTracker {
after: string
}>
> {
const gitPath = await this.getShadowGitPath()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
console.debug(`Using root commit as base: ${baseHash}`)
}
// Stage all changes so that untracked files appear in diff summary
await this.addAllFiles(git)
await this.gitOperations.addCheckpointFiles(git, gitPath)
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
console.info(`Found ${diffSummary.files.length} changed files`)
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
const files = diffSummary.files.map((f) => f.file)
const batchSize = 50
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(cwdPath, filePath)
// Get list of files that exist in base commit
const existingFiles = await this.getExistingFiles(git, baseHash, files)
let beforeContent = ""
try {
beforeContent = await git.show([`${baseHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
// Process files in batches
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize)
// Split batch into existing and new files
const existingBatch = batch.filter((file) => existingFiles.has(file))
const newBatch = batch.filter((file) => !existingFiles.has(file))
// Get before contents for existing files
let beforeContents: string[] = new Array(batch.length).fill("")
if (existingBatch.length > 0) {
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const args = ["show", "--format="]
existingBatch.forEach((file) => {
args.push(`${baseHash}:${file}`)
})
const beforeResult = await git.raw(args)
const existingContents = beforeResult.split("\n\0\n")
// Map contents back to original batch positions
existingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
beforeContents[batchIndex] = existingContents[index] || ""
}
})
}
let afterContent = ""
// Get after contents
let afterContents: string[] = []
if (rhsHash) {
// if user provided a newer commit, use git.show at that commit
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
// Split after files into existing and new in target commit
const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch)
const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file))
if (afterExistingBatch.length > 0) {
const args = ["show", "--format="]
afterExistingBatch.forEach((file) => {
args.push(`${rhsHash}:${file}`)
})
const afterResult = await git.raw(args)
const existingContents = afterResult.split("\n\0\n")
afterContents = new Array(batch.length).fill("")
afterExistingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
afterContents[batchIndex] = existingContents[index] || ""
}
})
}
} else {
// otherwise, read from disk (includes uncommitted changes)
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
// file might be deleted => remains empty
}
// Read from disk for working directory changes
afterContents = await Promise.all(
batch.map(async (filePath) => {
try {
return await fs.readFile(path.join(cwdPath, filePath), "utf8")
} catch (_) {
return ""
}
}),
)
}
result.push({
relativePath: filePath,
absolutePath,
before: beforeContent,
after: afterContent,
})
// Add results for this batch
for (let j = 0; j < batch.length; j++) {
const filePath = batch[j]
const absolutePath = path.join(cwdPath, filePath)
result.push({
relativePath: filePath,
absolutePath,
before: beforeContents[j] || "",
after: afterContents[j] || "",
})
}
}
return result
}
private async addAllFiles(git: SimpleGit) {
await this.renameNestedGitRepos(true)
/**
* Deletes all checkpoint data for a given task.
* Handles both legacy checkpoints and branch-per-task checkpoints.
*
* @param taskId - The ID of the task whose checkpoints should be deleted
* @param historyItem - The history item containing the shadow git config for this task
* @param globalStoragePath - the globalStorage path
* @throws Error if deletion fails
*/
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
}
/**
* Helper function to get a set of files that exist in a given commit
*/
private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise<Set<string>> {
try {
await git.add(".")
const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash])
const existingFiles = new Set<string>(result.split("\n"))
return existingFiles
} catch (error) {
console.error("Failed to add files to git:", error)
} finally {
await this.renameNestedGitRepos(false)
console.error("Error getting existing files:", error)
return new Set()
}
}
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
private async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}
const GIT_DISABLED_SUFFIX = "_disabled"
export default CheckpointTracker
@@ -0,0 +1,159 @@
import { mkdir } from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import os from "os"
import { fileExistsAtPath } from "../../utils/fs"
/**
* Gets the path to the legacy shadow Git repository in globalStorage.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory.
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @returns Promise<string> The absolute path to the legacy shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getLegacyShadowGitPath(globalStoragePath: string, taskId: string): Promise<string> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
await mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
console.info(`Legacy shadow git path: ${gitPath}`)
return gitPath
}
/**
* Gets the path to the shadow Git repository in globalStorage.
* For legacy checkpoints, delegates to getLegacyShadowGitPath().
* For new checkpoints, uses the consolidated branch-per-task structure.
*
* Branch-per-task path structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @param cwdHash - Hash of the working directory path
* @param isLegacyCheckpoint - Whether this is a legacy checkpoint
* @returns Promise<string> The absolute path to the shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getShadowGitPath(
globalStoragePath: string,
taskId: string,
cwdHash: string,
isLegacyCheckpoint: boolean,
): Promise<string> {
if (isLegacyCheckpoint) {
return getLegacyShadowGitPath(globalStoragePath, taskId)
}
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
await mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
/**
* Gets the current working directory from the VS Code workspace.
* Validates that checkpoints are not being used in protected directories
* like home, Desktop, Documents, or Downloads.
*
* Protected directories:
* - User's home directory
* - Desktop
* - Documents
* - Downloads
*
* @returns Promise<string> The absolute path to the current working directory
* @throws Error if no workspace is detected or if in a protected directory
*/
export async function getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
/**
* Hashes the current working directory to a 13-character numeric hash.
* @param workingDir - The absolute path to the working directory
* @returns A 13-character numeric hash string used to identify the workspace
* @throws {Error} If the working directory path is empty or invalid
*/
export function hashWorkingDir(workingDir: string): string {
if (!workingDir) {
throw new Error("Working directory path cannot be empty")
}
let hash = 0
for (let i = 0; i < workingDir.length; i++) {
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0
}
const bigHash = BigInt(hash)
const numericHash = bigHash.toString().slice(0, 13)
return numericHash
}
/**
* Detects if a task uses the legacy checkpoint structure.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory. New checkpoints use a single
* repository with branches per task.
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task to check
* @returns Promise<boolean> True if task uses legacy checkpoint structure, false otherwise
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* Branch-per-task structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*/
export async function detectLegacyCheckpoint(globalStoragePath: string | undefined, taskId: string): Promise<boolean> {
if (!globalStoragePath) {
return false
}
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
const isLegacy = await fileExistsAtPath(legacyGitPath)
console.info(`Legacy checkpoint detection result: ${isLegacy}`)
return isLegacy
}
+107
View File
@@ -0,0 +1,107 @@
import axios from "axios"
import ogs from "open-graph-scraper"
export interface OpenGraphData {
title?: string
description?: string
image?: string
url?: string
siteName?: string
type?: string
}
/**
* Fetches Open Graph metadata from a URL
* @param url The URL to fetch metadata from
* @returns Promise resolving to OpenGraphData
*/
export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
try {
const options = {
url: url,
timeout: 5000,
headers: {
"user-agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
},
onlyGetOpenGraphInfo: false, // Get all metadata, not just Open Graph
fetchOptions: {
redirect: "follow", // Follow redirects
} as any,
}
const { result } = await ogs(options)
// Use type assertion to avoid TypeScript errors
const data = result as any
// Handle image URLs
let imageUrl = data.ogImage?.[0]?.url || data.twitterImage?.[0]?.url
// If the image URL is relative, make it absolute
if (imageUrl && (imageUrl.startsWith("/") || imageUrl.startsWith("./"))) {
try {
// Extract the base URL and make the relative URL absolute
const urlObj = new URL(url)
const baseUrl = `${urlObj.protocol}//${urlObj.hostname}`
imageUrl = new URL(imageUrl, baseUrl).href
} catch (error) {
console.error(`Error converting relative URL to absolute: ${imageUrl}`, error)
}
}
return {
title: data.ogTitle || data.twitterTitle || data.dcTitle || data.title || new URL(url).hostname,
description:
data.ogDescription ||
data.twitterDescription ||
data.dcDescription ||
data.description ||
"No description available",
image: imageUrl,
url: data.ogUrl || url,
siteName: data.ogSiteName || new URL(url).hostname,
type: data.ogType,
}
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Return basic information based on the URL
try {
const urlObj = new URL(url)
return {
title: urlObj.hostname,
description: url,
url: url,
siteName: urlObj.hostname,
}
} catch {
return {
title: url,
description: url,
url: url,
}
}
}
}
/**
* Checks if a URL is an image by making a HEAD request and checking the content type
* @param url The URL to check
* @returns Promise resolving to boolean indicating if the URL is an image
*/
export async function isImageUrl(url: string): Promise<boolean> {
try {
const response = await axios.head(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; VSCodeExtension/1.0; +https://cline.bot)",
},
timeout: 3000,
})
const contentType = response.headers["content-type"]
return contentType && contentType.startsWith("image/")
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// If we can't determine, fall back to checking the file extension
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
}
}
+45
View File
@@ -0,0 +1,45 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
const apiKey = "phc_5WnLHpYyC30Bsb7VSJ6DzcPXZ34JSF08DJLyM7svZ15"
const apiHost = "https://us.i.posthog.com"
class PostHogClient {
private static instance: PostHogClient
private client: PostHog
private distinctId: string = vscode.env.machineId
private constructor() {
this.client = new PostHog(apiKey, {
host: apiHost,
enableExceptionAutocapture: true,
})
}
public static getInstance(): PostHogClient {
if (!PostHogClient.instance) {
PostHogClient.instance = new PostHogClient()
}
return PostHogClient.instance
}
public optIn(): void {
this.client.identify({ distinctId: this.distinctId })
this.client.optIn()
}
public optOut(): void {
this.client.optOut()
}
public capture(event: { event: string; properties?: any }): void {
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
}
// Export a single instance
export default PostHogClient.getInstance()
+19
View File
@@ -0,0 +1,19 @@
export interface ClineConfiguration {
vsCodeLmModelSelector?: {
vendor?: string
family?: string
}
mcp: {
mode: "full" | "server-use-only" | "off"
}
enableCheckpoints: boolean
enableTelemetry: boolean
}
export const DEFAULT_ADVANCED_SETTINGS: ClineConfiguration = {
mcp: {
mode: "full",
},
enableCheckpoints: true,
enableTelemetry: false,
}
+17
View File
@@ -7,6 +7,8 @@ import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
import { ClineConfiguration } from "./AdvancedSettings"
import { WorkspaceConfiguration } from "vscode"
// webview will hold state
export interface ExtensionMessage {
@@ -30,6 +32,8 @@ export interface ExtensionMessage {
| "mcpMarketplaceCatalog"
| "mcpDownloadDetails"
| "commitSearchResults"
| "openGraphData"
| "isImageUrlResult"
text?: string
action?:
| "chatButtonClicked"
@@ -54,6 +58,16 @@ export interface ExtensionMessage {
error?: string
mcpDownloadDetails?: McpDownloadResponse
commits?: GitCommit[]
openGraphData?: {
title?: string
description?: string
image?: string
url?: string
siteName?: string
type?: string
}
url?: string
isImage?: boolean
}
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
@@ -75,6 +89,9 @@ export interface ExtensionState {
chatSettings: ChatSettings
isLoggedIn: boolean
platform: Platform
advancedSettings: WorkspaceConfiguration | ClineConfiguration
vscMachineId: string
hideTelemetryOptIn: boolean
userInfo?: {
displayName: string | null
email: string | null
+73
View File
@@ -0,0 +1,73 @@
export type LanguageKey =
| "en"
| "ar"
| "pt-BR"
| "cs"
| "fr"
| "de"
| "hi"
| "hu"
| "it"
| "ja"
| "ko"
| "pl"
| "pt-PT"
| "ru"
| "zh-CN"
| "es"
| "zh-TW"
| "tr"
export type LanguageDisplay =
| "English"
| "Arabic - العربية"
| "Portuguese - Português (Brasil)"
| "Czech - Čeština"
| "French - Français"
| "German - Deutsch"
| "Hindi - हिन्दी"
| "Hungarian - Magyar"
| "Italian - Italiano"
| "Japanese - 日本語"
| "Korean - 한국어"
| "Polish - Polski"
| "Portuguese - Português (Portugal)"
| "Russian - Русский"
| "Simplified Chinese - 简体中文"
| "Spanish - Español"
| "Traditional Chinese - 繁體中文"
| "Turkish - Türkçe"
export const DEFAULT_LANGUAGE_SETTINGS: LanguageKey = "en"
export const languageOptions: { key: LanguageKey; display: LanguageDisplay }[] = [
{ key: "en", display: "English" },
{ key: "ar", display: "Arabic - العربية" },
{ key: "pt-BR", display: "Portuguese - Português (Brasil)" },
{ key: "cs", display: "Czech - Čeština" },
{ key: "fr", display: "French - Français" },
{ key: "de", display: "German - Deutsch" },
{ key: "hi", display: "Hindi - हिन्दी" },
{ key: "hu", display: "Hungarian - Magyar" },
{ key: "it", display: "Italian - Italiano" },
{ key: "ja", display: "Japanese - 日本語" },
{ key: "ko", display: "Korean - 한국어" },
{ key: "pl", display: "Polish - Polski" },
{ key: "pt-PT", display: "Portuguese - Português (Portugal)" },
{ key: "ru", display: "Russian - Русский" },
{ key: "zh-CN", display: "Simplified Chinese - 简体中文" },
{ key: "es", display: "Spanish - Español" },
{ key: "zh-TW", display: "Traditional Chinese - 繁體中文" },
{ key: "tr", display: "Turkish - Türkçe" },
]
export function getLanguageKey(display: LanguageDisplay | undefined): LanguageKey {
if (!display) {
return DEFAULT_LANGUAGE_SETTINGS
}
const languageOption = languageOptions.find((option) => option.display === display)
if (languageOption) {
return languageOption.key
}
return DEFAULT_LANGUAGE_SETTINGS
}
+8
View File
@@ -22,6 +22,7 @@ export interface WebviewMessage {
| "requestOllamaModels"
| "requestLmStudioModels"
| "openImage"
| "openInBrowser"
| "openFile"
| "openMention"
| "cancelTask"
@@ -50,6 +51,10 @@ export interface WebviewMessage {
| "searchCommits"
| "showMcpView"
| "fetchLatestMcpServersFromHub"
| "updateMcpTimeout"
| "fetchOpenGraphData"
| "checkIsImageUrl"
| "toggleTelemetryOptIn"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -68,6 +73,9 @@ export interface WebviewMessage {
serverName?: string
toolName?: string
autoApprove?: boolean
// For openInBrowser
url?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+19
View File
@@ -4,6 +4,25 @@ const vscode = require("vscode")
describe("Extension Tests", function () {
this.timeout(60000) // Increased timeout for extension operations
let originalGetConfiguration
beforeEach(() => {
// Save original configuration
originalGetConfiguration = vscode.workspace.getConfiguration
// Setup mock configuration
const mockUpdate = async () => Promise.resolve()
const mockConfig = {
get: () => true,
update: mockUpdate,
}
vscode.workspace.getConfiguration = () => mockConfig
})
afterEach(() => {
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should activate extension successfully", async () => {
// Get the extension
const extension = vscode.extensions.getExtension("saoudrizwan.claude-dev")
+29 -20
View File
@@ -1,23 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
build
*.local
coverage
# Environment
.env
.env.*
!.env.example
!.env.test
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+26
View File
@@ -0,0 +1,26 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ ignores: ["build"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cline Webview</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-16
View File
@@ -1,16 +0,0 @@
// "Official" jest workaround for mocking window.matchMedia()
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
+1718 -15814
View File
File diff suppressed because it is too large Load Diff
+38 -48
View File
@@ -1,67 +1,57 @@
{
"name": "webview-ui",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest dev"
},
"dependencies": {
"@floating-ui/react": "^0.27.4",
"@types/dompurify": "^3.0.5",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"mermaid": "^11.4.1",
"posthog-js": "^1.223.3",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
"react-scripts": "^5.0.1",
"react-textarea-autosize": "^8.5.3",
"react-use": "^17.5.1",
"react-virtuoso": "^4.7.13",
"rehype-highlight": "^7.0.0",
"rewire": "^7.0.0",
"styled-components": "^6.1.13",
"typescript": "^5.7.3",
"web-vitals": "^2.1.4"
},
"overrides": {
"typescript": "^5.7.3"
},
"scripts": {
"start": "react-scripts start",
"build": "node ./scripts/build-react-no-split.js",
"test": "vitest run",
"test:watch": "vitest dev",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"react-textarea-autosize": "^8.5.7",
"react-use": "^17.6.0",
"react-virtuoso": "^4.12.3",
"rehype-highlight": "^7.0.1",
"styled-components": "^6.1.15"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.6",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^20.x",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/vscode": "^1.84.0",
"@types/vscode-webview": "^1.57.5",
"jsdom": "^25.0.1",
"vitest": "^2.1.9"
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.1.1",
"vitest": "^3.0.5"
}
}
}
-38
View File
@@ -1,38 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
</html>
-25
View File
@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env node
/**
* A script that overrides some of the create-react-app build script configurations
* in order to disable code splitting/chunking and rename the output build files so
* they have no hash. (Reference: https://mtm.dev/disable-code-splitting-create-react-app).
*
* This is crucial for getting React webview code to run because VS Code expects a
* single (consistently named) JavaScript and CSS file when configuring webviews.
*/
const rewire = require("rewire")
const defaults = rewire("react-scripts/scripts/build.js")
const config = defaults.__get__("config")
const webpack = require("webpack")
/* Modifying Webpack Configuration for 'shared' dir
This section uses Rewire to modify Create React App's webpack configuration without ejecting. Rewire allows us to inject and alter the internal build scripts of CRA at runtime. This allows us to maintain a flexible project structure that keeps shared code outside the webview-ui/src directory, while still adhering to CRA's security model that typically restricts imports to within src/.
1. Uses the ModuleScopePlugin to whitelist files from the shared directory, allowing them to be imported despite being outside src/. (see: https://stackoverflow.com/questions/44114436/the-create-react-app-imports-restriction-outside-of-src-directory/58321458#58321458)
2. Modifies the TypeScript rule to include the shared directory in compilation. This essentially transpiles and includes the ts files in shared dir in the output main.js file.
Before, we would just import types from shared dir and specifying include (and alias to have cleaner paths) in tsconfig.json was enough. But now that we are creating values (i.e. models in api.ts) to import into the react app, we must also include these files in the webpack resolution.
- Imports from the shared directory must use full paths relative to the src directory, without file extensions.
- Example: import { someFunction } from '../../src/shared/utils/helpers'
*/
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin")
const path = require("path")
const fs = require("fs")
// Get all files in the shared directory
const sharedDir = path.resolve(__dirname, "..", "..", "src", "shared")
function getAllFiles(dir) {
let files = []
fs.readdirSync(dir).forEach((file) => {
const filePath = path.join(dir, file)
if (fs.statSync(filePath).isDirectory()) {
files = files.concat(getAllFiles(filePath))
} else {
// Skip test files
if (!file.endsWith(".test.ts")) {
const withoutExtension = path.join(dir, path.parse(file).name)
files.push(withoutExtension)
}
}
})
return files
}
const sharedFiles = getAllFiles(sharedDir)
// config.resolve.plugins = config.resolve.plugins.filter((plugin) => !(plugin instanceof ModuleScopePlugin))
// Instead of excluding the whole ModuleScopePlugin, we just whitelist specific files that can be imported from outside src.
config.resolve.plugins.forEach((plugin) => {
if (plugin instanceof ModuleScopePlugin) {
console.log("Whitelisting shared files: ", sharedFiles)
sharedFiles.forEach((file) => plugin.allowedFiles.add(file))
}
})
/*
Webpack configuration
Webpack is a module bundler for JavaScript applications. It processes your project files, resolving dependencies and generating a deployable production build.
The webpack config is an object that tells webpack how to process and bundle your code. It defines entry points, output settings, and how to handle different file types.
This config.module section of the webpack config deals with how different file types (modules) should be treated.
config.module.rules:
Rules define how module files should be processed. Each rule can:
- Specify which files to process (test)
When webpack "processes" a file, it performs several operations:
1. Reads the file
2. Parses its content and analyzes dependencies
3. Applies transformations (e.g., converting TypeScript to JavaScript)
4. Potentially modifies the code (e.g., applying polyfills)
5. Includes the processed file in the final bundle
By specifying which files to process, we're telling webpack which files should go through this pipeline and be included in our application bundle. Files that aren't processed are ignored by webpack.
In our case, we're ensuring that TypeScript files in our shared directory are processed, allowing us to use them in our application.
- Define which folders to include or exclude
- Set which loaders to use for transformation
A loader transforms certain types of files into valid modules that webpack can process. For example, the TypeScript loader converts .ts files into JavaScript that webpack can understand.
By modifying these rules, we can change how webpack processes different files in our project, allowing us to include files from outside the standard src directory.
Why we need to modify the webpack config
Create React App (CRA) is designed to only process files within the src directory for security reasons. (CRA limits processing to the src directory to prevent accidental inclusion of sensitive files, reduce the attack surface, and ensure predictable builds, enhancing overall project security and consistency. Therefore it's essential that if you do include files outside src, you do so explicitly.)
To use files from the shared directory, we need to:
1. Modify ModuleScopePlugin to allow imports from the shared directory.
2. Update the TypeScript loader rule to process TypeScript files from the shared directory.
These changes tell webpack it's okay to import from the shared directory and ensure that TypeScript files in this directory are properly converted to JavaScript.
Modify webpack configuration to process TypeScript files from shared directory
This code modifies the webpack configuration to allow processing of TypeScript files from our shared directory, which is outside the standard src folder.
1. config.module.rules[1]: In Create React App's webpack config, the second rule (index 1) typically contains the rules for processing JavaScript and TypeScript files.
2. .oneOf: This array contains a list of loaders, and webpack will use the first matching loader for each file. We iterate through these to find the TypeScript loader.
3. We check each rule to see if it applies to TypeScript files by looking for 'ts|tsx' in the test regex.
4. When we find the TypeScript rule, we add our shared directory to its 'include' array. This tells webpack to also process TypeScript files from the shared directory.
Note: This code assumes a specific structure in the CRA webpack config. If CRA updates its config structure in future versions, this code might need to be adjusted.
*/
config.module.rules[1].oneOf.forEach((rule) => {
if (rule.test && rule.test.toString().includes("ts|tsx")) {
// rule.include is path to src by default, but we can update rule.include to be an array as it matches an expected schema by react-scripts
rule.include = [rule.include, sharedDir].filter(Boolean)
}
})
// Force all code into a single bundle for VS Code webview compatibility.
// This is necessary for:
// 1. Mermaid.js to work properly (prevents async chunk loading)
// 2. Consistent CSP nonce handling (single bundle = single nonce)
config.optimization = {
...config.optimization,
splitChunks: {
cacheGroups: {
default: false,
},
name: "main", // Forces all chunks (dynamic import() calls, for example those used by Mermaid) into one bundle - this is what actually prevents code splitting
},
runtimeChunk: false,
}
// Ensure all chunks are named 'main' to match our CSP nonce setup
config.output = {
...config.output,
filename: "static/js/[name].js",
}
// Adjust build environment variables for dev/debug builds.
config.plugins[4] = new webpack.DefinePlugin({
"process.env": {
...config.plugins[4].definitions["process.env"],
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
IS_DEV: JSON.stringify(process.env.IS_DEV),
},
})
// Rename main.{hash}.css to main.css
config.plugins[5].options.filename = "static/css/[name].css"
config.plugins[5].options.moduleFilename = () => "static/css/main.css"
-2
View File
@@ -1,2 +0,0 @@
import "@testing-library/jest-dom"
import "./matchMedia"
+14 -1
View File
@@ -9,14 +9,17 @@ import AccountView from "./components/account/AccountView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView"
import posthog from "posthog-js"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
const { didHydrateState, showWelcome, shouldShowAnnouncement, advancedSettings, vscMachineId, hideTelemetryOptIn } =
useExtensionState()
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showMcp, setShowMcp] = useState(false)
const [showAccount, setShowAccount] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
const telemetryEnabled = advancedSettings.enableTelemetry
const handleMessage = useCallback((e: MessageEvent) => {
const message: ExtensionMessage = e.data
@@ -60,6 +63,15 @@ const AppContent = () => {
useEvent("message", handleMessage)
useEffect(() => {
if (telemetryEnabled) {
posthog.identify(vscMachineId)
posthog.opt_in_capturing()
} else {
posthog.opt_out_capturing()
}
}, [telemetryEnabled, vscMachineId])
useEffect(() => {
if (shouldShowAnnouncement) {
setShowAnnouncement(true)
@@ -93,6 +105,7 @@ const AppContent = () => {
hideAnnouncement={() => {
setShowAnnouncement(false)
}}
hideTelemetryOptIn={hideTelemetryOptIn}
/>
</>
)}
+71 -128
View File
@@ -25,6 +25,7 @@ import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import { highlightMentions } from "./TaskHeader"
import { CheckmarkControl } from "../common/CheckmarkControl"
import McpResponseDisplay from "../mcp/McpResponseDisplay"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -46,6 +47,35 @@ interface ChatRowProps {
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
export const ProgressIndicator = () => (
<div
style={{
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
<VSCodeProgressRing />
</div>
</div>
)
const Markdown = memo(({ markdown }: { markdown?: string }) => {
return (
<div
style={{
wordBreak: "break-word",
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
}}>
<MarkdownBlock markdown={markdown} />
</div>
)
})
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage } = props
@@ -53,7 +83,7 @@ const ChatRow = memo(
// This allows us to detect changes without causing re-renders
const prevHeightRef = useRef(0)
// NOTE: for tools that are interrupted and not responded to (approved or rejected), there won't be a checkpoint hash
// NOTE: for tools that are interrupted and not responded to (approved or rejected) there won't be a checkpoint hash
let shouldShowCheckpoints =
message.lastCheckpointHash != null &&
(message.say === "tool" ||
@@ -78,7 +108,7 @@ const ChatRow = memo(
)
useEffect(() => {
// used for partials, command output, etc.
// used for partials command output etc.
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
// height starts off at Infinity
@@ -90,7 +120,7 @@ const ChatRow = memo(
}
}, [height, isLast, onHeightChange, message])
// we cannot return null as virtuoso does not support it, so we use a separate visibleMessages array to filter out messages that should not be rendered
// we cannot return null as virtuoso does not support it so we use a separate visibleMessages array to filter out messages that should not be rendered
return chatrow
},
// memo does shallow comparison of props, so we need to do deep comparison of arrays/objects whose properties might change
@@ -101,7 +131,6 @@ export default ChatRow
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
@@ -111,11 +140,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}
return [undefined, undefined, undefined]
}, [message.text, message.say])
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
// when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
? lastModifiedMessage?.text
: undefined
const isCommandExecuting =
isLast &&
(lastModifiedMessage?.ask === "command" || lastModifiedMessage?.say === "command") &&
@@ -367,12 +398,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
Cline wants to read this file:
</span>
</div>
{/* <CodeAccordian
code={tool.content!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/> */}
<div
style={{
borderRadius: 3,
@@ -498,32 +523,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
/>
</>
)
// case "inspectSite":
// const isInspecting =
// isLast && lastModifiedMessage?.say === "inspect_site_result" && !lastModifiedMessage?.images
// return (
// <>
// <div style={headerStyle}>
// {isInspecting ? <ProgressIndicator /> : toolIcon("inspect")}
// <span style={{ fontWeight: "bold" }}>
// {message.type === "ask" ? (
// <>Cline wants to inspect this website:</>
// ) : (
// <>Cline is inspecting this website:</>
// )}
// </span>
// </div>
// <div
// style={{
// borderRadius: 3,
// border: "1px solid var(--vscode-editorGroup-border)",
// overflow: "hidden",
// backgroundColor: CODE_BLOCK_BG_COLOR,
// }}>
// <CodeBlock source={`${"```"}shell\n${tool.path}\n${"```"}`} forceWrap={true} />
// </div>
// </>
// )
default:
return null
}
@@ -570,10 +569,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{icon}
{title}
</div>
{/* <Terminal
rawOutput={command + (output ? "\n" + output : "")}
shouldAllowInput={!!isCommandExecuting && output.length > 0}
/> */}
<div
style={{
borderRadius: 3,
@@ -640,7 +635,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{useMcpServer.type === "access_mcp_resource" && (
<McpResourceRow
item={{
// Use the matched resource/template details, with fallbacks
...(findMatchingResourceOrTemplate(
useMcpServer.uri || "",
server?.resources,
@@ -650,7 +644,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
mimeType: "",
description: "",
}),
// Always use the actual URI from the request
uri: useMcpServer.uri || "",
}}
/>
@@ -742,6 +735,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{/* {apiProvider === "" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor:
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
color: "var(--vscode-editor-foreground)",
padding: "6px 8px",
borderRadius: "3px",
margin: "10px 0 0 0",
fontSize: "12px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 6,
fontSize: 16,
color: "var(--vscode-errorForeground)",
}}></i>
<span>
Uh-oh this could be a problem on end. We've been alerted and
will resolve this ASAP. You can also{" "}
<a
href=""
style={{ color: "inherit", textDecoration: "underline" }}>
contact us
</a>
.
</span>
</div>
)} */}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
@@ -759,39 +785,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</>
)}
</p>
{/* {apiProvider === "" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor:
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
color: "var(--vscode-editor-foreground)",
padding: "6px 8px",
borderRadius: "3px",
margin: "10px 0 0 0",
fontSize: "12px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 6,
fontSize: 16,
color: "var(--vscode-errorForeground)",
}}></i>
<span>
Uh-oh, this could be a problem on end. We've been alerted and
will resolve this ASAP. You can also{" "}
<a
href=""
style={{ color: "inherit", textDecoration: "underline" }}>
contact us
</a>
.
</span>
</div>
)} */}
</>
)}
@@ -809,6 +802,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "api_req_finished":
return null // we should never see this message type
case "mcp_server_response":
return <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div>
@@ -1101,28 +1096,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
</>
)
case "mcp_server_response":
return (
<>
<div style={{ paddingTop: 0 }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Response
</div>
<CodeAccordian
code={message.text}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
</>
)
default:
return (
<>
@@ -1174,7 +1147,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "completion_result":
if (message.text) {
// FIXME: is this ever even used?
const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
return (
@@ -1247,32 +1219,3 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}
}
}
export const ProgressIndicator = () => (
<div
style={{
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ transform: "scale(0.55)", transformOrigin: "center" }}>
<VSCodeProgressRing />
</div>
</div>
)
const Markdown = memo(({ markdown }: { markdown?: string }) => {
return (
<div
style={{
wordBreak: "break-word",
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
}}>
<MarkdownBlock markdown={markdown} />
</div>
)
})
+5 -2
View File
@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -26,17 +26,19 @@ import BrowserSessionRow from "./BrowserSessionRow"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
import TelemetryOptin from "./TelemetryOptin"
interface ChatViewProps {
isHidden: boolean
showAnnouncement: boolean
hideAnnouncement: () => void
showHistoryView: () => void
hideTelemetryOptIn: boolean
}
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView, hideTelemetryOptIn }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
@@ -790,6 +792,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
paddingBottom: "10px",
}}>
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
{!hideTelemetryOptIn && <TelemetryOptin />}
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
@@ -0,0 +1,86 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useState, useEffect } from "react"
import { getAsVar, VSC_INACTIVE_SELECTION_BACKGROUND } from "../../utils/vscStyles"
import { vscode } from "../../utils/vscode"
const boxStyles = {
backgroundColor: getAsVar(VSC_INACTIVE_SELECTION_BACKGROUND),
borderRadius: "3px",
padding: "12px 16px",
margin: "5px 15px 5px 15px",
position: "relative" as const,
flexShrink: 0,
minHeight: "120px",
display: "flex",
flexDirection: "column" as const,
justifyContent: "center" as const,
}
const TelemetryOptin = () => {
const [showThankYou, setShowThankYou] = useState(false)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
if (showThankYou) {
const fadeTimer = setTimeout(() => {
setIsVisible(false)
}, 1500)
const closeTimer = setTimeout(() => {
vscode.postMessage({ type: "toggleTelemetryOptIn", bool: true })
}, 2000)
return () => {
clearTimeout(fadeTimer)
clearTimeout(closeTimer)
}
}
}, [showThankYou])
const handleOptIn = () => {
setShowThankYou(true)
}
const handleCancel = () => {
vscode.postMessage({ type: "toggleTelemetryOptIn", bool: false })
}
if (showThankYou) {
return (
<div
style={{
...boxStyles,
opacity: isVisible ? 1 : 0,
transition: "opacity 0.5s ease-out",
textAlign: "center",
}}>
<h3 style={{ margin: "0" }}>Thank you for helping improve Cline!</h3>
</div>
)
}
return (
<div style={boxStyles}>
<h3 style={{ margin: "0 0 8px" }}>Help Improve Cline</h3>
<p style={{ margin: "0 0 12px" }}>
Would you like to help make Cline better by sending anonymous error reports and usage data? No personal or project
information will be collected. You can change this setting anytime in{" "}
<VSCodeLink onClick={() => vscode.postMessage({ type: "openExtensionSettings" })}>VS Code preferences</VSCodeLink>
.{" "}
<VSCodeLink href="https://github.com/cline/cline/blob/main/docs/PRIVACY.md" style={{ display: "inline" }}>
Learn more
</VSCodeLink>
</p>
<div style={{ display: "flex", gap: "8px" }}>
<VSCodeButton appearance="primary" onClick={handleOptIn}>
Opt In
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={handleCancel}>
Cancel
</VSCodeButton>
</div>
</div>
)
}
export default memo(TelemetryOptin)
@@ -0,0 +1,188 @@
import React, { useEffect, useState } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
interface OpenGraphData {
title?: string
description?: string
image?: string
url?: string
siteName?: string
type?: string
}
interface LinkPreviewProps {
url: string
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
useEffect(() => {
const fetchOpenGraphData = async () => {
try {
setLoading(true)
// Send a message to the extension to fetch Open Graph data
vscode.postMessage({
type: "fetchOpenGraphData",
text: url,
})
// Set up a listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "openGraphData" && message.url === url) {
setOgData(message.openGraphData)
setLoading(false)
window.removeEventListener("message", messageListener)
}
}
window.addEventListener("message", messageListener)
// Clean up the listener if the component unmounts
return () => {
window.removeEventListener("message", messageListener)
}
} catch (err) {
setError("Failed to fetch preview data")
setLoading(false)
}
}
// Fetch Open Graph data immediately when component mounts
fetchOpenGraphData()
}, [url])
// Fallback display while loading
if (loading) {
return (
<div
className="link-preview-loading"
style={{
padding: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
}}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {new URL(url).hostname}...
</div>
)
}
// Create a fallback object if ogData is null
const data = ogData || {
title: new URL(url).hostname,
description: "No description available",
siteName: new URL(url).hostname,
url: url,
}
// Render the Open Graph preview
return (
<div
className="link-preview"
style={{
display: "flex",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(data.image)}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
<div
className="link-preview-content"
style={{
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || new URL(url).hostname}
</div>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
)
}
export default LinkPreview
@@ -0,0 +1,460 @@
import React, { useEffect, useState, useCallback } from "react"
import { vscode } from "../../utils/vscode"
import LinkPreview from "./LinkPreview"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import DOMPurify from "dompurify"
// We'll use the backend isImageUrl function for HEAD requests
// This is a client-side fallback for data URLs and obvious image extensions
const isImageUrlSync = (str: string): boolean => {
// Check for data URLs which are definitely images
if (str.startsWith("data:image/")) {
return true
}
// Check for common image file extensions
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
}
export const isUrl = (str: string): boolean => {
// Basic URL validation
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
return urlPattern.test(str)
}
// Function to check if a URL is an image using HEAD request
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
// For data URLs, we can check synchronously
if (url.startsWith("data:image/")) {
return true
}
// For http/https URLs, we need to send a message to the extension
if (url.startsWith("http")) {
try {
// Create a promise that will resolve when we get a response
return new Promise((resolve) => {
// Set up a one-time listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "isImageUrlResult" && message.url === url) {
window.removeEventListener("message", messageListener)
resolve(message.isImage)
}
}
window.addEventListener("message", messageListener)
// Send the request to the extension
vscode.postMessage({
type: "checkIsImageUrl",
text: url,
})
// Set a timeout to avoid hanging indefinitely
setTimeout(() => {
window.removeEventListener("message", messageListener)
// Fall back to extension check
resolve(isImageUrlSync(url))
}, 3000)
})
} catch (error) {
console.error("Error checking if URL is an image:", error)
return isImageUrlSync(url)
}
}
// Fall back to extension check for other URLs
return isImageUrlSync(url)
}
// No longer needed as our regex directly extracts the URL part
// Helper to ensure URL is in a format that can be opened
export const formatUrlForOpening = (url: string): string => {
// If it's a data URI, return as is
if (url.startsWith("data:image/")) {
return url
}
// If it's a regular URL but doesn't have a protocol, add https://
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return `https://${url}`
}
return url
}
// Find all URLs (both image and regular) in an object
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
if (typeof obj === "object" && obj !== null) {
for (const value of Object.values(obj)) {
if (typeof value === "string") {
// First check with synchronous method
if (isImageUrlSync(value)) {
imageUrls.push(value)
} else if (isUrl(value)) {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(value).then((isImage) => {
if (isImage) {
imageUrls.push(value)
} else {
regularUrls.push(value)
}
})
pendingChecks.push(checkPromise)
}
} else if (typeof value === "object") {
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
imageUrls.push(...nestedUrls.imageUrls)
regularUrls.push(...nestedUrls.regularUrls)
})
pendingChecks.push(nestedUrlsPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
// Extract URLs from text using regex
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
// Match URLs with image: prefix and extract just the URL part
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
if (imageMatches) {
// Extract just the URL part from matches with image: prefix
const extractedUrls = imageMatches
.map((match) => {
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
return urlMatch ? urlMatch[1] : null
})
.filter(Boolean) as string[]
imageUrls.push(...extractedUrls)
}
// Match all URLs (including those that might be in the middle of paragraphs)
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
if (urlMatches) {
// Filter out URLs that are already in imageUrls
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
// Check each URL to see if it's an image
for (const url of filteredUrls) {
// First check with synchronous method
if (isImageUrlSync(url)) {
imageUrls.push(url)
} else {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(url).then((isImage) => {
if (isImage) {
imageUrls.push(url)
} else {
regularUrls.push(url)
}
})
pendingChecks.push(checkPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
const ResponseHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 10px;
color: var(--vscode-descriptionForeground);
cursor: pointer;
user-select: none;
border-bottom: 1px dashed var(--vscode-editorGroup-border);
margin-bottom: 8px;
.header-title {
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
`
const ToggleSwitch = styled.div`
display: flex;
align-items: center;
font-size: 12px;
color: var(--vscode-descriptionForeground);
.toggle-label {
margin-right: 8px;
}
.toggle-container {
position: relative;
width: 40px;
height: 20px;
background-color: var(--vscode-button-secondaryBackground);
border-radius: 10px;
cursor: pointer;
transition: background-color 0.3s;
}
.toggle-container.active {
background-color: var(--vscode-button-background);
}
.toggle-handle {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background-color: var(--vscode-button-foreground);
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-container.active .toggle-handle {
transform: translateX(20px);
}
`
const ResponseContainer = styled.div`
position: relative;
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 12px);
background-color: ${CODE_BLOCK_BG_COLOR};
color: var(--vscode-editor-foreground, #d4d4d4);
border-radius: 3px;
border: 1px solid var(--vscode-editorGroup-border);
overflow: hidden;
.response-content {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
padding: 10px;
}
`
// Style for URL text to ensure proper wrapping
const UrlText = styled.div`
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: break-word;
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 12px);
`
interface McpResponseDisplayProps {
responseText: string
}
// Represents a URL found in the text with its position and metadata
interface UrlMatch {
url: string // The actual URL
fullMatch: string // The full matched text (including any prefix like "image:")
index: number // Position in the text
isImage: boolean // Whether this URL is an image
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
}
const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText }) => {
const [isLoading, setIsLoading] = useState(true)
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
// Get saved preference from localStorage, default to 'rich'
const savedMode = localStorage.getItem("mcpDisplayMode")
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
})
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
const toggleDisplayMode = useCallback(() => {
const newMode = displayMode === "rich" ? "plain" : "rich"
setDisplayMode(newMode)
localStorage.setItem("mcpDisplayMode", newMode)
}, [displayMode])
// Find all URLs in the text and determine if they're images
useEffect(() => {
const processResponse = async () => {
setIsLoading(true)
try {
const text = responseText || ""
const matches: UrlMatch[] = []
const urlRegex = /https?:\/\/[^\s]+/g
let urlMatch: RegExpExecArray | null
while ((urlMatch = urlRegex.exec(text)) !== null) {
const url = urlMatch[0]
const fullMatch = url
matches.push({
url,
fullMatch,
index: urlMatch.index,
isImage: false, // Will check later
isProcessed: false,
})
}
// Check if URLs are images
for (const match of matches) {
match.isImage = await checkIfImageUrl(match.url)
}
// Sort by position in the text
matches.sort((a, b) => a.index - b.index)
setUrlMatches(matches)
} catch (error) {
console.error("Error processing MCP response:", error)
} finally {
setIsLoading(false)
}
}
processResponse()
}, [responseText])
// Function to render content based on display mode
const renderContent = () => {
// For plain text mode, just show the text
if (displayMode === "plain" || isLoading) {
return <UrlText>{responseText}</UrlText>
}
// For rich display mode, show the text with embedded content
if (displayMode === "rich" && !isLoading) {
// Create an array of text segments and embedded content
const segments: JSX.Element[] = []
let lastIndex = 0
let segmentIndex = 0
// Reset the processed flag for all URLs
const processedUrls = new Set<string>()
// Add the text before the first URL
if (urlMatches.length === 0) {
segments.push(<UrlText key={`segment-${segmentIndex}`}>{responseText}</UrlText>)
} else {
for (let i = 0; i < urlMatches.length; i++) {
const match = urlMatches[i]
const { url, fullMatch, index } = match
// Add text segment before this URL
if (index > lastIndex) {
segments.push(
<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex, index)}</UrlText>,
)
}
// Add the URL text itself
segments.push(<UrlText key={`url-${segmentIndex++}`}>{fullMatch}</UrlText>)
// Calculate the end position of this URL in the text
const urlEndIndex = index + fullMatch.length
// Add embedded content after the URL
if (match.isImage) {
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<img
src={DOMPurify.sanitize(url)}
alt={`Image for ${url}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
cursor: "pointer",
}}
onClick={() => {
const formattedUrl = formatUrlForOpening(url)
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formattedUrl),
})
}}
/>
</div>,
)
} else if (!processedUrls.has(url)) {
// For non-image URLs, only show the preview once
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<LinkPreview url={formatUrlForOpening(url)} />
</div>,
)
// Mark this URL as processed
processedUrls.add(url)
}
// Update lastIndex for next segment
lastIndex = urlEndIndex
}
// Add any remaining text after the last URL
if (lastIndex < responseText.length) {
segments.push(<UrlText key={`segment-${segmentIndex++}`}>{responseText.substring(lastIndex)}</UrlText>)
}
}
return <>{segments}</>
}
return null
}
try {
return (
<ResponseContainer>
<ResponseHeader>
<span className="header-title">Response</span>
<ToggleSwitch>
<span className="toggle-label">{displayMode === "rich" ? "Rich Display" : "Plain Text"}</span>
<div className={`toggle-container ${displayMode === "rich" ? "active" : ""}`} onClick={toggleDisplayMode}>
<div className="toggle-handle"></div>
</div>
</ToggleSwitch>
</ResponseHeader>
<div className="response-content">{renderContent()}</div>
</ResponseContainer>
)
} catch (error) {
console.error("Error parsing MCP response:", error)
return (
<ResponseContainer>
<ResponseHeader>
<span className="header-title">Response</span>
</ResponseHeader>
<div className="response-content">
<div>Error parsing response:</div>
<UrlText>{responseText}</UrlText>
</div>
</ResponseContainer>
)
}
}
export default McpResponseDisplay
@@ -11,6 +11,13 @@ type SettingsViewProps = {
onDone: () => void
}
const fetchMemoryBank = async () => {
const response = await fetch(
"https://raw.githubusercontent.com/cline/cline/refs/heads/main/docs/prompting/custom%20instructions%20library/raw-instructions/cline-memory-bank.md",
)
return await response.text()
}
const SettingsView = ({ onDone }: SettingsViewProps) => {
const { apiConfiguration, version, customInstructions, setCustomInstructions, openRouterModels } = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
@@ -103,6 +110,15 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
placeholder={'e.g. "Run unit tests at the end", "Use TypeScript with async/await", "Speak in Spanish"'}
onInput={(e: any) => setCustomInstructions(e.target?.value ?? "")}>
<span style={{ fontWeight: "500" }}>Custom Instructions</span>
<VSCodeLink
style={{ float: "right" }}
onClick={async () => {
setCustomInstructions(
`${customInstructions ? customInstructions + "\n" : ""}${await fetchMemoryBank()}`,
)
}}>
Add Memory Bank
</VSCodeLink>
</VSCodeTextArea>
<p
style={{
@@ -126,6 +126,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const apiKeyInput = screen.getByText("Supports Images")
expect(apiKeyInput).toBeInTheDocument()
})
@@ -136,6 +137,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const orgIdInput = screen.getByText("Context Window Size")
expect(orgIdInput).toBeInTheDocument()
})
@@ -146,6 +148,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const modelInput = screen.getByText("Max Output Tokens")
expect(modelInput).toBeInTheDocument()
})
@@ -9,6 +9,7 @@ import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { vscode } from "../utils/vscode"
import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings"
import { DEFAULT_CHAT_SETTINGS } from "../../../src/shared/ChatSettings"
import { DEFAULT_ADVANCED_SETTINGS } from "../../../src/shared/AdvancedSettings"
interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -39,6 +40,9 @@ export const ExtensionStateContextProvider: React.FC<{
chatSettings: DEFAULT_CHAT_SETTINGS,
isLoggedIn: false,
platform: DEFAULT_PLATFORM,
vscMachineId: "",
advancedSettings: DEFAULT_ADVANCED_SETTINGS,
hideTelemetryOptIn: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
-18
View File
@@ -1,18 +0,0 @@
import React from "react"
import ReactDOM from "react-dom/client"
import "./index.css"
import App from "./App"
import reportWebVitals from "./reportWebVitals"
import "../../node_modules/@vscode/codicons/dist/codicon.css"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { PostHogProvider } from "posthog-js/react"
import "./index.css"
import App from "./App.tsx"
import "../../node_modules/@vscode/codicons/dist/codicon.css"
const apiKey = "phc_5WnLHpYyC30Bsb7VSJ6DzcPXZ34JSF08DJLyM7svZ15"
const apiHost = "https://us.i.posthog.com"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<PostHogProvider apiKey={apiKey} options={{ api_host: apiHost }}>
<App />
</PostHogProvider>
</StrictMode>,
)
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
-15
View File
@@ -1,15 +0,0 @@
import { ReportHandler } from "web-vitals"
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry)
getFID(onPerfEntry)
getFCP(onPerfEntry)
getLCP(onPerfEntry)
getTTFB(onPerfEntry)
})
}
}
export default reportWebVitals
+18 -4
View File
@@ -1,5 +1,19 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom"
import { vi } from "vitest"
// "Official" jest workaround for mocking window.matchMedia()
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
+1
View File
@@ -1,4 +1,5 @@
export const VSC_INPUT_BACKGROUND = "--vscode-input-background"
export const VSC_INPUT_FOREGROUND = "--vscode-input-foreground"
export const VSC_SIDEBAR_BACKGROUND = "--vscode-sideBar-background"
export const VSC_FOREGROUND = "--vscode-foreground"
export const VSC_EDITOR_FOREGROUND = "--vscode-editor-foreground"
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["src/**/__tests__/**"]
}
+2 -19
View File
@@ -1,21 +1,4 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "../src/shared"],
"exclude": ["src/**/*.spec.ts", "setupTests.js", "matchMedia.js"]
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./setupTests.js"],
},
})
+43
View File
@@ -0,0 +1,43 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react-swc"
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
},
build: {
outDir: "build",
rollupOptions: {
output: {
inlineDynamicImports: true,
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
},
chunkSizeWarningLimit: 100000,
},
server: {
port: 25463,
hmr: {
host: "localhost",
protocol: "ws",
},
cors: {
origin: "*",
methods: "*",
allowedHeaders: "*",
},
},
define: {
"process.env": {
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
IS_DEV: JSON.stringify(process.env.IS_DEV),
},
},
})