Compare commits

...

3 Commits

Author SHA1 Message Date
Arafatkatze 3ff09d7550 feat: add unified debug logging system for extension and webview
Add a debug logging utility that writes both extension and webview logs
to a single file (~/cline-debug.log) for easier troubleshooting of
issues that span the extension/webview boundary.

- Extension logger with direct file writing and log rotation (10MB max)
- Webview logger that intercepts console calls and sends to extension
- Unified log format with timestamp, source, level, and message
- Documentation in CLAUDE.md for usage patterns
2026-01-04 15:34:12 -08:00
Robin Newhouse 0d04205dc4 fix: preserve file endings and trailing newlines across all edit tools (#8341) 2026-01-03 10:12:25 -08:00
Bee 4b9dbf11a0 feat: add Select UI component and Storybook story (#8355)
* feat: add Select UI component and Storybook story

Add @radix-ui/react-select dependency and introduce a Select Storybook
story to document and validate the new dropdown UI component.

* update position
2026-01-02 13:17:48 -08:00
15 changed files with 1027 additions and 59 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: preserve file endings and trailing newlines across all edit tools
+42
View File
@@ -18,6 +18,48 @@ This file is the secret sauce for working effectively in this codebase. It captu
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
## Debug Logging (For Development/Debugging Only)
**IMPORTANT: These utilities are for debugging purposes only, not production logging.**
The codebase has a unified debug logging system that writes both extension and webview logs to a single file: `~/cline-debug.log`. This makes it easy to troubleshoot issues that span the extension/webview boundary.
**Files:**
- `src/utils/debugLogger.ts` - Extension-side logger with file writing
- `webview-ui/src/utils/webviewDebugLogger.ts` - Webview-side logger that intercepts console calls and sends to extension
**Extension usage:**
```typescript
import { extensionLog } from '@/utils/debugLogger'
extensionLog.info('Task started:', taskId)
extensionLog.error('Failed to load:', error)
```
**Webview usage:**
```typescript
// In your app entry point (e.g., App.tsx or index.tsx):
import { enableWebviewDebugLogging } from './utils/webviewDebugLogger'
enableWebviewDebugLogging()
// Then use console normally - it gets logged to file:
console.log('Button clicked:', buttonId)
console.error('API failed:', error)
```
**Monitoring logs in real-time:**
```bash
tail -f ~/cline-debug.log
```
**How it works:**
1. Extension logs directly write to `~/cline-debug.log` using Node.js fs
2. Webview logs intercept console calls, stringify args, and send to extension via `postMessage` with type `webview_debug_log`
3. Extension receives `webview_debug_log` messages and appends them to the same file
4. Logs are formatted with timestamp, source (EXTENSION/WEBVIEW), level, and message
5. Automatic log rotation when file exceeds 10MB
**When to add webview message handler:**
If you're integrating the webview logger, you need to handle the `webview_debug_log` message type in the extension's message handler. Look for where other webview messages are processed and add handling for this type that calls `debugLog('webview', level, ...args)`.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
@@ -468,7 +468,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
changes[path] = {
type: PatchActionType.UPDATE,
oldContent: originalFiles[path],
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path).trimEnd(),
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path),
movePath: action.movePath,
}
break
@@ -480,8 +480,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
return
}
newContent = newContent.trimEnd() // remove any trailing newlines, since it's automatically inserted by the editor
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext }
}
+20 -3
View File
@@ -96,17 +96,34 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
throw new Error("User closed text editor, unable to edit file...")
}
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
const beginningOfDocument = new vscode.Position(0, 0)
this.activeDiffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
// Replace the text in the diff editor document.
const document = this.activeDiffEditor?.document
const document = this.activeDiffEditor.document
const edit = new vscode.WorkspaceEdit()
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
edit.replace(document.uri, range, content)
// IMPORTANT: VS Code may treat an out-of-bounds end position as an insertion instead of a
// replacement. Always validate the range against the current document to keep edits
// strictly within the real end-of-file.
const startLine = Math.max(0, Math.min(rangeToReplace.startLine, document.lineCount - 1))
const desiredEndLine = Math.max(rangeToReplace.startLine, rangeToReplace.endLine)
const validatedRange = document.validateRange(
new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(desiredEndLine, 0)),
)
edit.replace(document.uri, validatedRange, content)
await vscode.workspace.applyEdit(edit)
// Preserve trailing newline: if content ends with newline, ensure document does too
if (content.endsWith("\n") && !document.getText().endsWith("\n")) {
const fixEdit = new vscode.WorkspaceEdit()
fixEdit.insert(document.uri, document.lineAt(Math.max(0, document.lineCount - 1)).range.end, "\n")
await vscode.workspace.applyEdit(fixEdit)
}
if (currentLine !== undefined) {
// Update decorations for the entire changed section
this.activeLineController?.setActiveLine(currentLine)
+9 -13
View File
@@ -182,7 +182,12 @@ export abstract class DiffViewProvider {
// Replace all content up to the current line with accumulated lines
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags
// on previous lines are auto closed for example
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
let contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n")
if (!isFinal) {
// During streaming, add trailing newline for cursor positioning
contentToReplace += "\n"
}
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
@@ -212,15 +217,6 @@ export abstract class DiffViewProvider {
if (isFinal) {
// Handle any remaining lines if the new content is shorter than the original
await this.truncateDocument(this.streamedLines.length)
// Add empty last line if original content had one
const hasEmptyLastLine = this.originalContent?.endsWith("\n")
if (hasEmptyLastLine) {
const accumulatedLines = accumulatedContent.split("\n")
if (accumulatedLines[accumulatedLines.length - 1] !== "") {
accumulatedContent += "\n"
}
}
}
}
@@ -277,10 +273,10 @@ export abstract class DiffViewProvider {
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedPreSaveContent = preSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
const normalizedPostSaveContent = postSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // this is the final content we return to the model to use as the new baseline for future edits
const normalizedPreSaveContent = preSaveContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedPostSaveContent = postSaveContent.replace(/\r\n|\n/g, newContentEOL) // this is the final content we return to the model to use as the new baseline for future edits
// just in case the new content has a mix of varying EOL characters
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL)
let userEdits: string | undefined
if (normalizedPreSaveContent !== normalizedNewContent) {
+34 -5
View File
@@ -43,19 +43,48 @@ export class FileEditProvider extends DiffViewProvider {
// Split the document into lines
const lines = this.documentContent.split("\n")
const originalEndsWithNewline = this.documentContent.endsWith("\n")
// If original ends with newline, split creates a trailing empty string that isn't a real line.
// Remove it for line-based operations, we'll add it back at the end if needed.
const realLines = originalEndsWithNewline && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines
// Replace the specified range with the new content
const newContentLines = content.split("\n")
// Remove trailing empty line if present in newContentLines for proper splicing
if (newContentLines[newContentLines.length - 1] === "") {
const contentEndsWithNewline = content.endsWith("\n")
// Determine if we're replacing to the end of the document
const replacingToEnd = rangeToReplace.endLine >= realLines.length
// Handle trailing empty string from split:
// - If content ends with \n, split creates an empty string at the end
// - When replacing to end: this empty string becomes the document's trailing newline - keep it
// - When replacing middle: this empty string would create an extra newline - remove it
// (the join operation will naturally add newlines between lines)
// - If content doesn't end with \n but split created empty string, remove it
if (!contentEndsWithNewline && newContentLines[newContentLines.length - 1] === "") {
newContentLines.pop()
} else if (contentEndsWithNewline && !replacingToEnd && newContentLines[newContentLines.length - 1] === "") {
// Content ends with newline but we're replacing middle section - remove trailing empty string
newContentLines.pop()
}
// Splice the lines array to replace the range
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newContentLines)
// Splice the real lines array to replace the range
realLines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newContentLines)
// Join the lines back together
this.documentContent = lines.join("\n")
let result = realLines.join("\n")
// Preserve trailing newline: add it back if original had one OR if we replaced to end with content that ends with newline
const shouldHaveTrailingNewline = originalEndsWithNewline || (replacingToEnd && contentEndsWithNewline)
if (shouldHaveTrailingNewline && !result.endsWith("\n")) {
result += "\n"
} else if (!shouldHaveTrailingNewline && result.endsWith("\n")) {
// Shouldn't have trailing newline but result has one - remove it
result = result.slice(0, -1)
}
this.documentContent = result
}
protected async scrollEditorToLine(_line: number): Promise<void> {
+54
View File
@@ -0,0 +1,54 @@
import * as assert from "assert"
import { describe, it } from "mocha"
import { FileEditProvider } from "../integrations/editor/FileEditProvider"
describe("FileEditProvider Trailing Newline", () => {
// Helper to set up provider without calling open()
function setupProvider(initialContent: string): FileEditProvider {
const provider = new FileEditProvider()
provider["isEditing"] = true
provider["documentContent"] = initialContent
provider["originalContent"] = initialContent
return provider
}
it("preserves trailing newline when content ends with newline", async () => {
const provider = setupProvider("line1\nline2\n")
await provider.replaceText("new1\nnew2\n", { startLine: 0, endLine: 2 }, undefined)
const result = await provider.getContent()
assert.strictEqual(result, "new1\nnew2\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("does not add trailing newline when content does not end with newline", async () => {
const provider = setupProvider("line1\nline2")
await provider.replaceText("new1\nnew2", { startLine: 0, endLine: 2 }, undefined)
const result = await provider.getContent()
assert.strictEqual(result, "new1\nnew2")
assert.strictEqual(result?.endsWith("\n"), false)
})
it("preserves trailing newline when replacing middle section", async () => {
const provider = setupProvider("line1\nline2\nline3\n")
await provider.replaceText("new2\n", { startLine: 1, endLine: 2 }, undefined)
const result = await provider.getContent()
assert.strictEqual(result, "line1\nnew2\nline3\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("handles file without trailing newline correctly", async () => {
const provider = setupProvider("line1\nline2")
await provider.replaceText("new1\nnew2\n", { startLine: 0, endLine: 2 }, undefined)
const result = await provider.getContent()
assert.strictEqual(result, "new1\nnew2\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
})
+82
View File
@@ -0,0 +1,82 @@
import * as assert from "assert"
import { describe, it } from "mocha"
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
class TestDiffViewProvider extends DiffViewProvider {
public documentText: string = ""
public replacements: { content: string; range: { startLine: number; endLine: number } }[] = []
async openDiffEditor(): Promise<void> {}
async scrollEditorToLine(line: number): Promise<void> {}
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
async truncateDocument(lineNumber: number): Promise<void> {
const lines = this.documentText.split("\n")
this.documentText = lines.slice(0, lineNumber).join("\n")
}
async getDocumentText(): Promise<string | undefined> {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
async resetDiffView(): Promise<void> {}
async replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
currentLine: number | undefined,
): Promise<void> {
this.replacements.push({ content, range: rangeToReplace })
// Simulate the replacement
const lines = this.documentText.split("\n")
const newLines = content.split("\n")
// Preserve trailing newline logic (simplified)
if (!content.endsWith("\n") && newLines[newLines.length - 1] === "") {
newLines.pop()
}
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newLines)
this.documentText = lines.join("\n")
}
public setup(initialContent: string) {
this.isEditing = true
this.documentText = initialContent
this.originalContent = initialContent
}
}
describe("DiffViewProvider Newline handling", () => {
it("preserves trailing newline through update() when content ends with newline", async () => {
const provider = new TestDiffViewProvider()
provider.setup("line1\nline2\n")
await provider.update("new1\nnew2\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("does not add trailing newline when content does not end with newline", async () => {
const provider = new TestDiffViewProvider()
provider.setup("line1\nline2")
await provider.update("new1\nnew2", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2")
assert.strictEqual(result?.endsWith("\n"), false)
})
it("handles file without trailing newline correctly", async () => {
const provider = new TestDiffViewProvider()
provider.setup("[6]: http://chris.beams.io/posts/git-commit/#seven-rules")
await provider.update("new content\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new content\n")
assert.strictEqual(result?.endsWith("\n"), true)
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* Unified Debug Logger
*
* **IMPORTANT: This is for debugging purposes only.**
*
* Provides logging utilities for both extension and webview to help troubleshoot issues.
* All logs are written to ~/cline-debug.log for easy access during development and debugging.
*
* This logger is NOT for production logging - it's specifically designed to help developers
* diagnose problems during development, testing, or when users report issues.
*
* Usage:
* import { debugLog, extensionLog } from '@/utils/debugLogger'
* extensionLog.info('Task started:', taskId)
* debugLog('extension', 'warn', 'Something unexpected happened')
*
* Monitoring logs in real-time:
* tail -f ~/cline-debug.log
*/
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
// Single unified log file for both extension and webview
export const DEBUG_LOG_PATH = path.join(os.homedir(), "cline-debug.log")
// Max log file size before rotation (10MB)
const MAX_LOG_SIZE = 10 * 1024 * 1024
/**
* Log levels
*/
export type LogLevel = "debug" | "info" | "warn" | "error"
/**
* Log source (extension or webview)
*/
export type LogSource = "extension" | "webview"
/**
* Format a log entry with timestamp, source, and level
*/
function formatLogEntry(source: LogSource, level: LogLevel, args: unknown[]): string {
const timestamp = new Date().toISOString()
const levelUpper = level.toUpperCase().padEnd(5) // Align columns
const sourceLabel = `[${source.toUpperCase()}]`.padEnd(11) // Align columns
// Convert args to strings
const message = args
.map((arg) => {
if (arg === undefined) return "undefined"
if (arg === null) return "null"
if (typeof arg === "string") return arg
if (typeof arg === "number" || typeof arg === "boolean") return String(arg)
if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack}`
try {
return JSON.stringify(arg, null, 2)
} catch {
return String(arg)
}
})
.join(" ")
return `[${timestamp}] ${sourceLabel} [${levelUpper}] ${message}\n`
}
/**
* Check if log file needs rotation and rotate if necessary
*/
function rotateLogIfNeeded(): void {
try {
if (fs.existsSync(DEBUG_LOG_PATH)) {
const stats = fs.statSync(DEBUG_LOG_PATH)
if (stats.size > MAX_LOG_SIZE) {
const rotatedPath = `${DEBUG_LOG_PATH}.old`
// Remove old backup if it exists
if (fs.existsSync(rotatedPath)) {
fs.unlinkSync(rotatedPath)
}
// Rotate current log to backup
fs.renameSync(DEBUG_LOG_PATH, rotatedPath)
}
}
} catch (error) {
// Silently fail - don't let logging errors break the app
console.error("[debugLogger] Failed to rotate log:", error)
}
}
/**
* Write a log entry to the debug log file
*
* @param source - Where the log is coming from (extension or webview)
* @param level - Log level (debug, info, warn, error)
* @param args - Arguments to log (will be stringified)
*/
export function debugLog(source: LogSource, level: LogLevel, ...args: unknown[]): void {
try {
// Check if rotation is needed
rotateLogIfNeeded()
// Format and append log entry
const logEntry = formatLogEntry(source, level, args)
fs.appendFileSync(DEBUG_LOG_PATH, logEntry, "utf8")
} catch (error) {
// Silently fail - don't let logging errors break the app
console.error("[debugLogger] Failed to write log:", error)
}
}
/**
* Async version of debugLog for non-blocking logging
* Recommended for high-frequency logging scenarios
*/
export async function debugLogAsync(source: LogSource, level: LogLevel, ...args: unknown[]): Promise<void> {
try {
// Check if rotation is needed
rotateLogIfNeeded()
// Format and append log entry
const logEntry = formatLogEntry(source, level, args)
await fs.promises.appendFile(DEBUG_LOG_PATH, logEntry, "utf8")
} catch (error) {
// Silently fail - don't let logging errors break the app
console.error("[debugLogger] Failed to write log:", error)
}
}
/**
* Convenience wrappers for extension logging
*
* Use these for debugging extension-side code:
* - extensionLog.debug() - Verbose debugging info
* - extensionLog.info() - General informational messages
* - extensionLog.warn() - Warnings about potential issues
* - extensionLog.error() - Error conditions
*/
export const extensionLog = {
debug: (...args: unknown[]) => debugLog("extension", "debug", ...args),
info: (...args: unknown[]) => debugLog("extension", "info", ...args),
warn: (...args: unknown[]) => debugLog("extension", "warn", ...args),
error: (...args: unknown[]) => debugLog("extension", "error", ...args),
}
/**
* Convenience wrappers for webview logging
*
* Use these for debugging webview-side code:
* - webviewLog.debug() - Verbose debugging info
* - webviewLog.info() - General informational messages
* - webviewLog.warn() - Warnings about potential issues
* - webviewLog.error() - Error conditions
*/
export const webviewLog = {
debug: (...args: unknown[]) => debugLog("webview", "debug", ...args),
info: (...args: unknown[]) => debugLog("webview", "info", ...args),
warn: (...args: unknown[]) => debugLog("webview", "warn", ...args),
error: (...args: unknown[]) => debugLog("webview", "error", ...args),
}
/**
* Clear the debug log file
*
* Useful when starting a fresh debugging session
*/
export function clearDebugLog(): void {
try {
if (fs.existsSync(DEBUG_LOG_PATH)) {
fs.unlinkSync(DEBUG_LOG_PATH)
}
} catch (error) {
console.error("[debugLogger] Failed to clear log:", error)
}
}
/**
* Get the debug log file path
*
* Returns: ~/cline-debug.log
*/
export function getDebugLogPath(): string {
return DEBUG_LOG_PATH
}
+103 -35
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
@@ -171,7 +172,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -570,7 +570,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -594,7 +593,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -604,7 +602,6 @@
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
"integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.8.1"
}
@@ -1126,7 +1123,6 @@
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz",
"integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/component": "0.6.18",
"@firebase/logger": "0.4.4",
@@ -1193,7 +1189,6 @@
"resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz",
"integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/app": "0.13.2",
"@firebase/component": "0.6.18",
@@ -1209,8 +1204,7 @@
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz",
"integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@firebase/auth-compat": {
"version": "0.5.28",
@@ -1661,7 +1655,6 @@
"integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
},
@@ -2886,7 +2879,6 @@
"resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.22.tgz",
"integrity": "sha512-+RVuAxjS2QWyLdYTPxv0IfMjhsxa1GKRSwvpii13bOGEQclwwfaNL2MvBbTt1Mzu/LHaX7kyj0THbZnlOplZOA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@heroui/react-utils": "2.1.13",
"@heroui/system-rsc": "2.3.19",
@@ -2981,7 +2973,6 @@
"resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.22.tgz",
"integrity": "sha512-naKFQBfp7YwhKGmh7rKCC5EBjV7kdozX21fyGHucDYa6GeFfIKVqXILgZ94HZlfp+LGJfV6U+BuKIflevf0Y+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@heroui/shared-utils": "2.1.11",
"clsx": "^1.2.1",
@@ -3764,6 +3755,12 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -3793,6 +3790,32 @@
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -3859,6 +3882,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
@@ -4139,6 +4177,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -6941,7 +7022,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -7413,7 +7495,6 @@
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -7425,7 +7506,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -7898,7 +7978,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -8052,7 +8131,6 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -8387,7 +8465,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -8788,7 +8865,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9065,7 +9141,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.7",
@@ -9146,7 +9223,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -9455,7 +9531,6 @@
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.18.tgz",
"integrity": "sha512-HBVXBL5x3nk/0WrYM5G4VgjBey99ytVYET5AX17s/pcnlH90cyaxVUqgoN8cpF4+PqZRVOhwWsv28F+hxA9Tzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"motion-dom": "^12.23.18",
"motion-utils": "^12.23.6",
@@ -10542,7 +10617,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -11036,6 +11110,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -11966,6 +12041,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -11981,6 +12057,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -12053,7 +12130,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -12098,7 +12174,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -12112,7 +12187,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-remark": {
"version": "2.1.0",
@@ -12634,7 +12710,6 @@
"integrity": "sha512-/vFSi3I+ya/D75UZh5GxLc/6UQ+KoKPEvL9autr1yGcaeWzXBQr1tTXmNDS4FImFCPwBAvVe7j9YzR8PQ5rfqw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -12980,7 +13055,6 @@
"integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -13274,7 +13348,6 @@
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
@@ -13303,8 +13376,7 @@
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -13619,8 +13691,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.2",
@@ -13628,7 +13699,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14041,7 +14111,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14169,7 +14238,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
+1
View File
@@ -24,6 +24,7 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
@@ -0,0 +1,221 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "./select"
const meta: Meta = {
title: "Ui/Select",
component: Select,
parameters: {
docs: {
description: {
component:
"A select dropdown component built on Radix UI. Allows users to choose from a list of options with support for grouping, separators, labels, and custom styling. Includes trigger, content, item, and value components for composing select layouts.",
},
},
},
}
export default meta
type StoryProps = {
placeholder: string
size: "sm" | "default"
items: string[]
defaultValue?: string
disabled: boolean
showGroups: boolean
showSeparators: boolean
}
// Interactive story with controls
export const Interactive: StoryObj<StoryProps> = {
args: {
placeholder: "Select an option",
size: "default",
items: ["Option 1", "Option 2", "Option 3", "Option 4"],
defaultValue: undefined,
disabled: false,
showGroups: false,
showSeparators: false,
},
argTypes: {
placeholder: {
control: "text",
description: "Placeholder text shown when no option is selected",
},
size: {
control: "select",
options: ["sm", "default"],
description: "Size variant of the select trigger",
},
items: {
control: "object",
description: "Array of items to display in the select",
},
defaultValue: {
control: "text",
description: "Default selected value",
},
disabled: {
control: "boolean",
description: "Disable the select component",
},
showGroups: {
control: "boolean",
description: "Show items organized in groups with labels",
},
showSeparators: {
control: "boolean",
description: "Show separators between items",
},
},
render: (args) => (
<div className="w-full h-full flex justify-center items-center overflow-hidden">
<div className="flex flex-col justify-center items-center h-[60%] w-[50%] overflow-hidden mt-50">
<div className="flex justify-center my-5">
<ClineLogoWhite className="size-16" />
</div>
<p>
You can customize the select using the controls in the "Controls" panel below to change its placeholder, size,
items, and styling options.
</p>
<div className="mt-4.5">
<Select defaultValue={args.defaultValue} disabled={args.disabled}>
<SelectTrigger size={args.size}>
<SelectValue placeholder={args.placeholder} />
</SelectTrigger>
<SelectContent position="popper">
{args.showGroups ? (
<>
<SelectGroup>
<SelectLabel>Group 1</SelectLabel>
{args.items.slice(0, Math.ceil(args.items.length / 2)).map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectGroup>
{args.showSeparators && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Group 2</SelectLabel>
{args.items.slice(Math.ceil(args.items.length / 2)).map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectGroup>
</>
) : (
args.items.map((item, index) => (
<>
<SelectItem key={item} value={item}>
{item}
</SelectItem>
{args.showSeparators && index < args.items.length - 1 && <SelectSeparator />}
</>
))
)}
</SelectContent>
</Select>
</div>
</div>
</div>
),
}
// Showcase all select variants
export const Overview = () => {
const variants = [
{
label: "Basic",
size: "default" as const,
placeholder: "Select a fruit",
items: ["Apple", "Banana", "Cherry", "Date", "Elderberry"],
hasGroups: false,
hasSeparators: false,
},
{
label: "With Groups",
size: "default" as const,
placeholder: "Select a language",
groups: [
{
label: "Frontend",
items: ["JavaScript", "TypeScript", "HTML", "CSS"],
},
{
label: "Backend",
items: ["Python", "Java", "Go", "Rust"],
},
],
hasGroups: true,
hasSeparators: false,
},
{
label: "With Separators",
size: "default" as const,
placeholder: "Select a tool",
items: ["Git", "Docker", "Kubernetes", "Jenkins"],
hasGroups: false,
hasSeparators: true,
},
]
return (
<div className="w-screen">
<div className="flex justify-center h-[60%] w-[80%] overflow-hidden gap-8 p-8">
{variants.map((variant) => (
<div className="flex flex-col gap-4" key={variant.label}>
<h2 className="text-lg font-semibold">{variant.label}</h2>
<Select>
<SelectTrigger size={variant.size}>
<SelectValue placeholder={variant.placeholder} />
</SelectTrigger>
<SelectContent position="popper">
{variant.hasGroups && "groups" in variant ? (
variant?.groups?.map((group, groupIndex) => (
<>
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectGroup>
{groupIndex < variant.groups.length - 1 && <SelectSeparator />}
</>
))
) : (
<>
{"items" in variant &&
variant?.items?.map((item, index) => (
<>
<SelectItem key={item} value={item}>
{item}
</SelectItem>
{variant.hasSeparators && index < variant.items.length - 1 && (
<SelectSeparator />
)}
</>
))}
</>
)}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
)
}
+153
View File
@@ -0,0 +1,153 @@
"use client"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
className={cn(
"border-editor-group-border data-[placeholder]:text-input-placeholder [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-editor-group-border focus-visible:ring-ring/20 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-error flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-2",
className,
)}
data-size={size}
data-slot="select-trigger"
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
align={align}
className={cn(
"bg-menu text-menu-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xs border border-editor-group-border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
data-slot="select-content"
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("text-input-placeholder/80 px-2 py-1 text-sm", className)}
data-slot="select-label"
{...props}
/>
)
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"focus:bg-button-background/50 focus:text-input [&_svg:not([class*='text-'])]:text-description relative flex w-full cursor-default items-center gap-2 rounded-xs py-1 pr-8 pl-2 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-2 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
data-slot="select-item"
{...props}>
<span className="absolute right-2 flex size-2 items-center justify-center" data-slot="select-item-indicator">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-2" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("bg-editor-group-border pointer-events-none -mx-1 my-1 h-px", className)}
data-slot="select-separator"
{...props}
/>
)
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
data-slot="select-scroll-up-button"
{...props}>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
data-slot="select-scroll-down-button"
{...props}>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Webview Debug Logger
*
* **IMPORTANT: This is for debugging purposes only.**
*
* Intercepts console.log/warn/error/debug calls and sends them to the extension,
* which writes them to ~/cline-debug.log (unified with extension logs).
*
* This allows developers to see webview console output in the same log file as
* extension logs, making it easier to debug issues that span both sides of the
* extension/webview boundary.
*
* Usage:
* 1. Import and enable early in your app entry point:
* import { enableWebviewDebugLogging } from './utils/webviewDebugLogger'
* enableWebviewDebugLogging()
*
* 2. Use console methods normally - they'll be intercepted and logged:
* console.log('User clicked button:', buttonId)
* console.warn('API request took longer than expected')
* console.error('Failed to load data:', error)
*
* 3. Monitor logs in real-time:
* tail -f ~/cline-debug.log
*
* Note: The extension must handle the 'webview_debug_log' message type for this to work.
*/
import { PLATFORM_CONFIG } from "../config/platform.config"
// Store original console methods
const originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
debug: console.debug.bind(console),
}
// Convert any value to a string for logging
function stringify(arg: unknown): string {
if (arg === undefined) return "undefined"
if (arg === null) return "null"
if (typeof arg === "string") return arg
if (typeof arg === "number" || typeof arg === "boolean") return String(arg)
if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack}`
try {
return JSON.stringify(arg, null, 2)
} catch {
return String(arg)
}
}
// Send log to extension
function sendToExtension(level: "log" | "warn" | "error" | "debug", args: unknown[]) {
try {
PLATFORM_CONFIG.postMessage({
type: "webview_debug_log",
webview_debug_log: {
level,
args: args.map(stringify),
timestamp: Date.now(),
},
})
} catch {
// Silently fail if postMessage isn't available
}
}
// Create wrapped console method
function createWrapper(level: "log" | "warn" | "error" | "debug") {
return (...args: unknown[]) => {
// Call original console method
originalConsole[level](...args)
// Send to extension for file logging
sendToExtension(level, args)
}
}
/**
* Enable webview debug logging.
* Call this once at app startup to intercept all console calls.
*
* After enabling, all console.log/warn/error/debug calls will:
* 1. Display in the browser console as normal
* 2. Be sent to the extension and written to ~/cline-debug.log
*/
export function enableWebviewDebugLogging() {
console.log = createWrapper("log")
console.warn = createWrapper("warn")
console.error = createWrapper("error")
console.debug = createWrapper("debug")
// Log that we've enabled debug logging
console.log("[WebviewDebugLogger] Debug logging enabled. Logs written to ~/cline-debug.log")
}
/**
* Disable webview debug logging and restore original console methods.
* Useful if you want to temporarily disable logging or clean up on unmount.
*/
export function disableWebviewDebugLogging() {
console.log = originalConsole.log
console.warn = originalConsole.warn
console.error = originalConsole.error
console.debug = originalConsole.debug
}
/**
* Log directly to file without going through console.
* Useful for logging in hot paths where you don't want console output,
* or when you want to log something that shouldn't appear in the browser console.
*
* Example:
* logToFile('debug', 'Performance metric:', performanceData)
*/
export function logToFile(level: "log" | "warn" | "error" | "debug", ...args: unknown[]) {
sendToExtension(level, args)
}