data.ai / FileOperationToolView.tsx
lattmamb's picture
Upload 251 files (#1)
67c7241 verified
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>
);
}