Spaces:
Running
Running
| import React, { useState } from "react"; | |
| import { FileCode, FileSymlink, FolderPlus, FileX, Replace, CheckCircle, AlertTriangle, ExternalLink, CircleDashed, Code, Eye, FileSpreadsheet } from "lucide-react"; | |
| import { ToolViewProps } from "./types"; | |
| import { extractFilePath, extractFileContent, getFileType, formatTimestamp, getToolTitle } from "./utils"; | |
| import { GenericToolView } from "./GenericToolView"; | |
| import { MarkdownRenderer, processUnicodeContent } from "@/components/file-renderers/markdown-renderer"; | |
| import { CsvRenderer } from "@/components/file-renderers/csv-renderer"; | |
| import { cn } from "@/lib/utils"; | |
| import { useTheme } from "next-themes"; | |
| import { CodeBlockCode } from "@/components/ui/code-block"; | |
| import { constructHtmlPreviewUrl } from "@/lib/utils/url"; | |
| // Type for operation type | |
| type FileOperation = "create" | "rewrite" | "delete"; | |
| // Map file extensions to language names for syntax highlighting | |
| const getLanguageFromFileName = (fileName: string): string => { | |
| const extension = fileName.split('.').pop()?.toLowerCase() || ''; | |
| // Map of file extensions to language names for syntax highlighting | |
| const extensionMap: Record<string, string> = { | |
| // Web languages | |
| 'html': 'html', | |
| 'htm': 'html', | |
| 'css': 'css', | |
| 'scss': 'scss', | |
| 'sass': 'scss', | |
| 'less': 'less', | |
| 'js': 'javascript', | |
| 'jsx': 'jsx', | |
| 'ts': 'typescript', | |
| 'tsx': 'tsx', | |
| 'json': 'json', | |
| 'jsonc': 'json', | |
| // Build and config files | |
| 'xml': 'xml', | |
| 'yml': 'yaml', | |
| 'yaml': 'yaml', | |
| 'toml': 'toml', | |
| 'ini': 'ini', | |
| 'env': 'bash', | |
| 'gitignore': 'bash', | |
| 'dockerignore': 'bash', | |
| // Scripting languages | |
| 'py': 'python', | |
| 'rb': 'ruby', | |
| 'php': 'php', | |
| 'go': 'go', | |
| 'java': 'java', | |
| 'kt': 'kotlin', | |
| 'c': 'c', | |
| 'cpp': 'cpp', | |
| 'h': 'c', | |
| 'hpp': 'cpp', | |
| 'cs': 'csharp', | |
| 'swift': 'swift', | |
| 'rs': 'rust', | |
| // Shell scripts | |
| 'sh': 'bash', | |
| 'bash': 'bash', | |
| 'zsh': 'bash', | |
| 'ps1': 'powershell', | |
| 'bat': 'batch', | |
| 'cmd': 'batch', | |
| // Markup languages (excluding markdown which has its own renderer) | |
| 'svg': 'svg', | |
| 'tex': 'latex', | |
| // Data formats | |
| 'graphql': 'graphql', | |
| 'gql': 'graphql', | |
| }; | |
| return extensionMap[extension] || 'text'; | |
| }; | |
| export function FileOperationToolView({ | |
| assistantContent, | |
| toolContent, | |
| assistantTimestamp, | |
| toolTimestamp, | |
| isSuccess = true, | |
| isStreaming = false, | |
| name, | |
| project | |
| }: ToolViewProps) { | |
| const { resolvedTheme } = useTheme(); | |
| const isDarkTheme = resolvedTheme === 'dark'; | |
| // Determine operation type from content or name | |
| const getOperationType = (): FileOperation => { | |
| // First check tool name if available | |
| if (name) { | |
| if (name.includes("create")) return "create"; | |
| if (name.includes("rewrite")) return "rewrite"; | |
| if (name.includes("delete")) return "delete"; | |
| } | |
| if (!assistantContent) return "create"; // default fallback | |
| if (assistantContent.includes("<create-file>")) return "create"; | |
| if (assistantContent.includes("<full-file-rewrite>")) return "rewrite"; | |
| if (assistantContent.includes("delete-file") || assistantContent.includes("<delete>")) return "delete"; | |
| // Check for tool names as a fallback | |
| if (assistantContent.toLowerCase().includes("create file")) return "create"; | |
| if (assistantContent.toLowerCase().includes("rewrite file")) return "rewrite"; | |
| if (assistantContent.toLowerCase().includes("delete file")) return "delete"; | |
| // Default to create if we can't determine | |
| return "create"; | |
| }; | |
| const operation = getOperationType(); | |
| const filePath = extractFilePath(assistantContent); | |
| const toolTitle = getToolTitle(name || `file-${operation}`); | |
| // Only extract content for create and rewrite operations | |
| const fileContent = operation !== "delete" | |
| ? extractFileContent(assistantContent, operation === "create" ? 'create-file' : 'full-file-rewrite') | |
| : null; | |
| // For debugging - show raw content if file path can't be extracted for delete operations | |
| const showDebugInfo = !filePath && operation === "delete"; | |
| // Process file path - handle potential newlines and clean up | |
| const processedFilePath = filePath ? filePath.trim().replace(/\\n/g, '\n').split('\n')[0] : null; | |
| // For create and rewrite, prepare content for display | |
| const contentLines = fileContent ? fileContent.replace(/\\n/g, '\n').split('\n') : []; | |
| const fileName = processedFilePath ? processedFilePath.split('/').pop() || processedFilePath : ''; | |
| const fileType = processedFilePath ? getFileType(processedFilePath) : ''; | |
| const isMarkdown = fileName.endsWith('.md'); | |
| const isHtml = fileName.endsWith('.html'); | |
| const isCsv = fileName.endsWith('.csv'); | |
| const language = getLanguageFromFileName(fileName); | |
| const hasHighlighting = language !== 'text'; | |
| // Construct HTML file preview URL if we have a sandbox and the file is HTML | |
| const htmlPreviewUrl = (isHtml && project?.sandbox?.sandbox_url && processedFilePath) | |
| ? constructHtmlPreviewUrl(project.sandbox.sandbox_url, processedFilePath) | |
| : undefined; | |
| console.log('HTML Preview URL:', htmlPreviewUrl); | |
| // Add state for view mode toggle (code or preview) | |
| const [viewMode, setViewMode] = useState<'code' | 'preview'>(isHtml || isMarkdown || isCsv ? 'preview' : 'code'); | |
| // Fall back to generic view if file path is missing or if content is missing for non-delete operations | |
| if ((!filePath && !showDebugInfo) || (operation !== "delete" && !fileContent)) { | |
| return ( | |
| <GenericToolView | |
| name={name || `file-${operation}`} | |
| assistantContent={assistantContent} | |
| toolContent={toolContent} | |
| assistantTimestamp={assistantTimestamp} | |
| toolTimestamp={toolTimestamp} | |
| isSuccess={isSuccess} | |
| isStreaming={isStreaming} | |
| /> | |
| ); | |
| } | |
| // Operation-specific configs | |
| const configs = { | |
| create: { | |
| icon: FolderPlus, | |
| successMessage: "File created successfully" | |
| }, | |
| rewrite: { | |
| icon: Replace, | |
| successMessage: "File rewritten successfully" | |
| }, | |
| delete: { | |
| icon: FileX, | |
| successMessage: "File deleted successfully" | |
| } | |
| }; | |
| const config = configs[operation]; | |
| const Icon = config.icon; | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| <div className="flex-1 p-4 overflow-auto"> | |
| {/* File Content for create and rewrite operations */} | |
| {operation !== "delete" && fileContent && !isStreaming && ( | |
| <div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden shadow-sm bg-white dark:bg-zinc-950 h-full flex flex-col"> | |
| {/* IDE Header */} | |
| <div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 justify-between border-b border-zinc-200 dark:border-zinc-800"> | |
| <div className="flex items-center"> | |
| {isMarkdown ? | |
| <FileCode className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> : | |
| isCsv ? | |
| <FileSpreadsheet className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> : | |
| <FileSymlink className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> | |
| } | |
| <span className="text-xs font-medium">{fileName}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {/* View switcher for HTML files */} | |
| {isHtml && htmlPreviewUrl && isSuccess && ( | |
| <div className="flex rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"> | |
| <button | |
| onClick={() => setViewMode('code')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'code' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Code className="h-3 w-3" /> | |
| <span>Code</span> | |
| </button> | |
| <button | |
| onClick={() => setViewMode('preview')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'preview' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Eye className="h-3 w-3" /> | |
| <span>Preview</span> | |
| </button> | |
| </div> | |
| )} | |
| {/* View switcher for Markdown files */} | |
| {isMarkdown && isSuccess && ( | |
| <div className="flex rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"> | |
| <button | |
| onClick={() => setViewMode('code')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'code' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Code className="h-3 w-3" /> | |
| <span>Code</span> | |
| </button> | |
| <button | |
| onClick={() => setViewMode('preview')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'preview' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Eye className="h-3 w-3" /> | |
| <span>Preview</span> | |
| </button> | |
| </div> | |
| )} | |
| {/* View switcher for CSV files */} | |
| {isCsv && isSuccess && ( | |
| <div className="flex rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"> | |
| <button | |
| onClick={() => setViewMode('code')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'code' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Code className="h-3 w-3" /> | |
| <span>Code</span> | |
| </button> | |
| <button | |
| onClick={() => setViewMode('preview')} | |
| className={cn( | |
| "flex items-center gap-1 text-xs px-2 py-1 transition-colors", | |
| viewMode === 'preview' | |
| ? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100" | |
| : "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700" | |
| )} | |
| > | |
| <Eye className="h-3 w-3" /> | |
| <span>Preview</span> | |
| </button> | |
| </div> | |
| )} | |
| <span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-200 dark:bg-zinc-800 px-2 py-0.5 rounded"> | |
| {hasHighlighting ? language.toUpperCase() : fileType} | |
| </span> | |
| </div> | |
| </div> | |
| {/* File Content (Code View with Syntax Highlighting) */} | |
| {viewMode === 'code' || (!isHtml && !isMarkdown && !isCsv) || !isSuccess ? ( | |
| <div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100"> | |
| {hasHighlighting ? ( | |
| <div className="relative"> | |
| <div className="absolute left-0 top-0 bottom-0 w-12 border-r border-zinc-200 dark:border-zinc-800 z-10 flex flex-col"> | |
| {contentLines.map((_, idx) => ( | |
| <div key={idx} | |
| className="h-6 text-right pr-3 text-xs font-mono text-zinc-500 dark:text-zinc-500 select-none"> | |
| {idx + 1} | |
| </div> | |
| ))} | |
| </div> | |
| <div className="pl-12"> | |
| <CodeBlockCode | |
| code={processUnicodeContent(fileContent)} | |
| language={language} | |
| className="text-xs p-2" | |
| /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="min-w-full table"> | |
| {contentLines.map((line, idx) => ( | |
| <div key={idx} className="table-row hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors"> | |
| <div className="table-cell text-right pr-3 py-0.5 text-xs font-mono text-zinc-500 dark:text-zinc-500 select-none w-12 border-r border-zinc-200 dark:border-zinc-800"> | |
| {idx + 1} | |
| </div> | |
| <div className="table-cell pl-3 py-0.5 text-xs font-mono whitespace-pre text-zinc-800 dark:text-zinc-300"> | |
| {processUnicodeContent(line) || ' '} | |
| </div> | |
| </div> | |
| ))} | |
| <div className="table-row h-4"></div> | |
| </div> | |
| )} | |
| </div> | |
| ) : null} | |
| {/* HTML Preview with iframe */} | |
| {isHtml && viewMode === 'preview' && htmlPreviewUrl && isSuccess && ( | |
| <div className="flex-1 bg-white overflow-hidden"> | |
| <iframe | |
| src={htmlPreviewUrl} | |
| title={`HTML Preview of ${fileName}`} | |
| className="w-full h-full border-0" | |
| style={{ minHeight: "300px" }} | |
| sandbox="allow-same-origin allow-scripts" | |
| /> | |
| </div> | |
| )} | |
| {/* Markdown Preview */} | |
| {isMarkdown && viewMode === 'preview' && isSuccess && ( | |
| <div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100"> | |
| <MarkdownRenderer content={processUnicodeContent(fileContent)} /> | |
| </div> | |
| )} | |
| {/* CSV Preview */} | |
| {isCsv && viewMode === 'preview' && isSuccess && ( | |
| <div className="flex-1 overflow-hidden bg-white dark:bg-zinc-950"> | |
| <CsvRenderer content={processUnicodeContent(fileContent)} /> | |
| </div> | |
| )} | |
| {/* External link button for HTML files */} | |
| {isHtml && viewMode === 'preview' && htmlPreviewUrl && isSuccess && ( | |
| <div className="bg-zinc-100 dark:bg-zinc-900 p-2 border-t border-zinc-200 dark:border-zinc-800 flex justify-end"> | |
| <a | |
| href={htmlPreviewUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-1.5 py-1 px-2 text-xs text-zinc-700 dark:text-zinc-300 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded transition-colors" | |
| > | |
| <ExternalLink className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" /> | |
| <span>Open in Browser</span> | |
| </a> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* File Content for streaming state */} | |
| {operation !== "delete" && isStreaming && ( | |
| <div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden shadow-sm bg-white dark:bg-zinc-950 h-full flex flex-col"> | |
| {/* IDE Header */} | |
| <div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 justify-between border-b border-zinc-200 dark:border-zinc-800"> | |
| <div className="flex items-center"> | |
| <FileSymlink className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> | |
| <span className="text-xs font-medium">{fileName || 'file.txt'}</span> | |
| </div> | |
| <span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-200 dark:bg-zinc-800 px-2 py-0.5 rounded"> | |
| {fileType || 'Text'} | |
| </span> | |
| </div> | |
| {/* Streaming state */} | |
| <div className="flex-1 flex items-center justify-center p-8 bg-white dark:bg-zinc-950"> | |
| <div className="text-center"> | |
| <CircleDashed className="h-8 w-8 mx-auto mb-3 text-blue-500 animate-spin" /> | |
| <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300"> | |
| {operation === "create" ? "Creating file..." : "Rewriting file..."} | |
| </p> | |
| <p className="text-xs mt-1 text-zinc-500 dark:text-zinc-400"> | |
| {processedFilePath || "Processing file operation"} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete view with file path */} | |
| {operation === "delete" && processedFilePath && !isStreaming && ( | |
| <div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col"> | |
| <div className="p-6 flex-1 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100"> | |
| <div className="w-14 h-14 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center mb-4"> | |
| <FileX className="h-7 w-7 text-red-600 dark:text-red-400" /> | |
| </div> | |
| <h3 className="text-lg font-medium mb-4 text-red-600 dark:text-red-400">File Deleted</h3> | |
| <div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md p-4 w-full max-w-md text-center mb-2"> | |
| <code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">{processedFilePath}</code> | |
| </div> | |
| <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">This file has been permanently removed</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete view streaming state */} | |
| {operation === "delete" && isStreaming && ( | |
| <div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col"> | |
| <div className="p-6 flex-1 flex flex-col items-center justify-center bg-white dark:bg-zinc-950"> | |
| <div className="text-center"> | |
| <CircleDashed className="h-8 w-8 mx-auto mb-3 text-blue-500 animate-spin" /> | |
| <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Deleting file...</p> | |
| {processedFilePath && ( | |
| <p className="text-xs mt-2 font-mono text-zinc-500 dark:text-zinc-400 break-all"> | |
| {processedFilePath} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete view with unknown path */} | |
| {operation === "delete" && !processedFilePath && !showDebugInfo && !isStreaming && ( | |
| <div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col"> | |
| <div className="p-6 flex-1 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100"> | |
| <div className="w-14 h-14 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center mb-4"> | |
| <FileX className="h-7 w-7 text-red-600 dark:text-red-400" /> | |
| </div> | |
| <h3 className="text-lg font-medium mb-4 text-red-600 dark:text-red-400">File Deleted</h3> | |
| <div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md p-4 w-full max-w-md text-center mb-2"> | |
| <p className="text-sm text-zinc-700 dark:text-zinc-300">Unknown file path</p> | |
| </div> | |
| <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">A file has been deleted but the path could not be determined</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="p-4 border-t border-zinc-200 dark:border-zinc-800"> | |
| <div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400"> | |
| {!isStreaming && ( | |
| <div className="flex items-center gap-2"> | |
| {isSuccess ? ( | |
| <CheckCircle className="h-3.5 w-3.5 text-emerald-500" /> | |
| ) : ( | |
| <AlertTriangle className="h-3.5 w-3.5 text-red-500" /> | |
| )} | |
| <span> | |
| {isSuccess ? config.successMessage : `Failed to ${operation} file`} | |
| </span> | |
| </div> | |
| )} | |
| {isStreaming && ( | |
| <div className="flex items-center gap-2"> | |
| <CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin" /> | |
| <span>Processing file operation...</span> | |
| </div> | |
| )} | |
| <div className="text-xs"> | |
| {toolTimestamp && !isStreaming | |
| ? formatTimestamp(toolTimestamp) | |
| : assistantTimestamp | |
| ? formatTimestamp(assistantTimestamp) | |
| : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |