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> | |
); | |
} |