Merge pull request #1422 from kubet/fix/visual-improvements-file

fix: visual improvements file
This commit is contained in:
kubet 2025-08-22 19:53:21 +02:00 committed by GitHub
commit 578a8e4a0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 339 additions and 127 deletions

View File

@ -254,7 +254,7 @@ export function FileAttachment({
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering the main click handler
try {
if (!sandboxId || !session?.access_token) {
// Fallback: open file URL in new tab
@ -451,8 +451,9 @@ export function FileAttachment({
className={cn(
"group relative w-full",
"rounded-xl border bg-card overflow-hidden pt-10", // Consistent card styling with header space
isPdf ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[500px] sm:!min-w-[300px]" :
standalone ? "min-h-[300px] h-auto" : "h-[300px]", // Better height handling for standalone
isPdf ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[500px] sm:!min-w-[300px]" :
isHtmlOrMd ? "!min-h-[200px] sm:min-h-0 sm:h-[400px] max-h-[600px] sm:!min-w-[300px]" :
standalone ? "min-h-[300px] h-auto" : "h-[300px]", // Better height handling for standalone
className
)}
style={{
@ -469,8 +470,8 @@ export function FileAttachment({
style={{
minWidth: 0,
width: '100%',
containIntrinsicSize: isPdf ? '100% 500px' : undefined,
contain: isPdf ? 'layout size' : undefined
containIntrinsicSize: (isPdf || isHtmlOrMd) ? '100% 500px' : undefined,
contain: (isPdf || isHtmlOrMd) ? 'layout size' : undefined
}}
>
{/* Render PDF or text-based previews */}

View File

@ -3,29 +3,68 @@ import {
FileDiff,
CheckCircle,
AlertTriangle,
ExternalLink,
Loader2,
Code,
Eye,
File,
ChevronDown,
ChevronUp,
Copy,
Check,
Minus,
Plus,
} from 'lucide-react';
import {
extractFilePath,
extractFileContent,
extractStreamingFileContent,
formatTimestamp,
getToolTitle,
normalizeContentToString,
extractToolData,
} from '../utils';
import {
MarkdownRenderer,
processUnicodeContent,
} from '@/components/file-renderers/markdown-renderer';
import { CsvRenderer } from '@/components/file-renderers/csv-renderer';
import { XlsxRenderer } from '@/components/file-renderers/xlsx-renderer';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useTheme } from 'next-themes';
import { CodeBlockCode } from '@/components/ui/code-block';
import { constructHtmlPreviewUrl } from '@/lib/utils/url';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
extractFileEditData,
generateLineDiff,
calculateDiffStats,
LineDiff,
DiffStats
DiffStats,
getLanguageFromFileName,
getOperationType,
getOperationConfigs,
getFileIcon,
processFilePath,
getFileName,
getFileExtension,
isFileType,
hasLanguageHighlighting,
splitContentIntoLines,
type FileOperation,
type OperationConfig,
} from './_utils';
import { formatTimestamp, getToolTitle } from '../utils';
import { ToolViewProps } from '../types';
import { GenericToolView } from '../GenericToolView';
import { LoadingState } from '../shared/LoadingState';
import { toast } from 'sonner';
import ReactDiffViewer from 'react-diff-viewer-continued';
const UnifiedDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCode, newCode }) => (
@ -60,40 +99,6 @@ const UnifiedDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCo
/>
);
const SplitDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCode, newCode }) => (
<ReactDiffViewer
oldValue={oldCode}
newValue={newCode}
splitView={true}
useDarkTheme={document.documentElement.classList.contains('dark')}
styles={{
variables: {
dark: {
diffViewerColor: '#e2e8f0',
diffViewerBackground: '#09090b',
addedBackground: '#104a32',
addedColor: '#6ee7b7',
removedBackground: '#5c1a2e',
removedColor: '#fca5a5',
},
},
diffContainer: {
backgroundColor: 'var(--card)',
border: 'none',
},
gutter: {
backgroundColor: 'var(--muted)',
'&:hover': {
backgroundColor: 'var(--accent)',
},
},
line: {
fontFamily: 'monospace',
},
}}
/>
);
const ErrorState: React.FC<{ message?: string }> = ({ message }) => (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="text-center w-full max-w-xs">
@ -116,8 +121,42 @@ export function FileEditToolView({
toolTimestamp,
isSuccess = true,
isStreaming = false,
project,
}: ToolViewProps): JSX.Element {
const [viewMode, setViewMode] = useState<'unified' | 'split'>('unified');
const { resolvedTheme } = useTheme();
const isDarkTheme = resolvedTheme === 'dark';
// Add copy functionality state
const [isCopyingContent, setIsCopyingContent] = useState(false);
// Copy functions
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
};
const handleCopyContent = async () => {
if (!updatedContent) return;
setIsCopyingContent(true);
const success = await copyToClipboard(updatedContent);
if (success) {
toast.success('File content copied to clipboard');
} else {
toast.error('Failed to copy file content');
}
setTimeout(() => setIsCopyingContent(false), 500);
};
const operation = getOperationType(name, assistantContent);
const configs = getOperationConfigs();
const config = configs[operation] || configs['edit']; // fallback to edit config
const Icon = FileDiff; // Always use FileDiff for edit operations
const {
filePath,
@ -135,103 +174,275 @@ export function FileEditToolView({
);
const toolTitle = getToolTitle(name);
const processedFilePath = processFilePath(filePath);
const fileName = getFileName(processedFilePath);
const fileExtension = getFileExtension(fileName);
const isMarkdown = isFileType.markdown(fileExtension);
const isHtml = isFileType.html(fileExtension);
const isCsv = isFileType.csv(fileExtension);
const isXlsx = isFileType.xlsx(fileExtension);
const language = getLanguageFromFileName(fileName);
const hasHighlighting = hasLanguageHighlighting(language);
const contentLines = splitContentIntoLines(updatedContent);
const htmlPreviewUrl =
isHtml && project?.sandbox?.sandbox_url && processedFilePath
? constructHtmlPreviewUrl(project.sandbox.sandbox_url, processedFilePath)
: undefined;
const FileIcon = getFileIcon(fileName);
const lineDiff = originalContent && updatedContent ? generateLineDiff(originalContent, updatedContent) : [];
const stats: DiffStats = calculateDiffStats(lineDiff);
const shouldShowError = !isStreaming && (!actualIsSuccess || (actualIsSuccess && (originalContent === null || updatedContent === null)));
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
<FileDiff className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
if (!isStreaming && !processedFilePath && !updatedContent) {
return (
<GenericToolView
name={name || 'edit-file'}
assistantContent={assistantContent}
toolContent={toolContent}
assistantTimestamp={assistantTimestamp}
toolTimestamp={toolTimestamp}
isSuccess={isSuccess}
isStreaming={isStreaming}
/>
);
}
{!isStreaming && (
<Badge
variant="secondary"
className={
actualIsSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{actualIsSuccess ? 'Edit applied' : 'Edit failed'}
</Badge>
)}
const renderFilePreview = () => {
if (!updatedContent) {
return (
<div className="flex items-center justify-center h-full p-12">
<div className="text-center">
<FileIcon className="h-12 w-12 mx-auto mb-4 text-zinc-400" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">No content to preview</p>
</div>
</div>
</CardHeader>
);
}
<CardContent className="p-0 flex-1 flex flex-col min-h-0">
{isStreaming ? (
<LoadingState
icon={FileDiff}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
title="Applying File Edit"
filePath={filePath || 'Processing file...'}
progressText="Analyzing changes"
subtitle="Please wait while the file is being modified"
if (isHtml && htmlPreviewUrl) {
return (
<div className="flex flex-col h-[calc(100vh-16rem)]">
<iframe
src={htmlPreviewUrl}
title={`HTML Preview of ${fileName}`}
className="flex-grow border-0"
sandbox="allow-same-origin allow-scripts"
/>
) : shouldShowError ? (
<ErrorState message={errorMessage} />
) : (
<div className="flex-1 flex flex-col min-h-0">
<div className="shrink-0 p-3 border-b border-zinc-200 dark:border-zinc-800 bg-accent flex items-center justify-between">
<div className="flex items-center">
<File className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300">
{filePath || 'Unknown file'}
</code>
</div>
</div>
);
}
<div className="flex items-center gap-2">
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 gap-3">
{stats.additions === 0 && stats.deletions === 0 ? (
<Badge variant="outline" className="text-xs font-normal">No changes</Badge>
) : (
<>
<div className="flex items-center">
<Plus className="h-3.5 w-3.5 text-emerald-500 mr-1" />
<span>{stats.additions}</span>
</div>
<div className="flex items-center">
<Minus className="h-3.5 w-3.5 text-red-500 mr-1" />
<span>{stats.deletions}</span>
</div>
</>
)}
</div>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'unified' | 'split')} className="w-auto">
<TabsList className="h-7 p-0.5">
<TabsTrigger value="unified" className="text-xs h-6 px-2">Unified</TabsTrigger>
<TabsTrigger value="split" className="text-xs h-6 px-2">Split</TabsTrigger>
</TabsList>
</Tabs>
if (isMarkdown) {
return (
<div className="p-1 py-0 prose dark:prose-invert prose-zinc max-w-none">
<MarkdownRenderer
content={processUnicodeContent(updatedContent)}
/>
</div>
);
}
if (isCsv) {
return (
<div className="h-full w-full p-4">
<div className="h-[calc(100vh-17rem)] w-full bg-muted/20 border rounded-xl overflow-auto">
<CsvRenderer content={processUnicodeContent(updatedContent)} />
</div>
</div>
);
}
if (isXlsx) {
return (
<div className="h-full w-full p-4">
<div className="h-[calc(100vh-17rem)] w-full bg-muted/20 border rounded-xl overflow-auto">
<XlsxRenderer
content={updatedContent}
filePath={processedFilePath}
fileName={fileName}
project={project}
/>
</div>
</div>
);
}
return (
<div className="p-4">
<div className='w-full h-full bg-muted/20 border rounded-xl px-4 py-2 pb-6'>
<pre className="text-sm font-mono text-zinc-800 dark:text-zinc-300 whitespace-pre-wrap break-words">
{processUnicodeContent(updatedContent)}
</pre>
</div>
</div>
);
};
const renderSourceCode = () => {
if (!originalContent || !updatedContent) {
return (
<div className="flex items-center justify-center h-full p-12">
<div className="text-center">
<FileIcon className="h-12 w-12 mx-auto mb-4 text-zinc-400" />
<p className="text-sm text-zinc-500 dark:text-zinc-400">No diff to display</p>
</div>
</div>
);
}
// Show unified diff view in source tab
return (
<div className="flex-1 overflow-auto min-h-0 text-xs">
<UnifiedDiffView oldCode={originalContent} newCode={updatedContent} />
</div>
);
};
return (
<Card className="flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<Tabs defaultValue={isMarkdown || isHtml || isCsv || isXlsx ? 'preview' : 'code'} className="w-full h-full">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2 mb-0">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
<FileDiff className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
</div>
<div className="flex-1 overflow-auto min-h-0 text-xs">
{viewMode === 'unified' ? (
<UnifiedDiffView oldCode={originalContent!} newCode={updatedContent!} />
) : (
<SplitDiffView oldCode={originalContent!} newCode={updatedContent!} />
<div className='flex items-center gap-2'>
{isHtml && htmlPreviewUrl && !isStreaming && (
<Button variant="outline" size="sm" className="h-8 text-xs bg-white dark:bg-muted/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 shadow-none" asChild>
<a href={htmlPreviewUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
Open in Browser
</a>
</Button>
)}
{/* Copy button - only show when there's file content */}
{updatedContent && !isStreaming && (
<Button
variant="outline"
size="sm"
onClick={handleCopyContent}
disabled={isCopyingContent}
className="h-8 text-xs bg-white dark:bg-muted/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 shadow-none"
title="Copy file content"
>
{isCopyingContent ? (
<Check className="h-3.5 w-3.5 mr-1.5" />
) : (
<Copy className="h-3.5 w-3.5 mr-1.5" />
)}
<span className="hidden sm:inline">Copy</span>
</Button>
)}
{/* Diff mode selector for source tab */}
{originalContent && updatedContent && (
<div className="flex items-center gap-2">
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 gap-3">
{stats.additions === 0 && stats.deletions === 0 && (
<Badge variant="outline" className="text-xs font-normal">No changes</Badge>
)}
</div>
</div>
)}
<TabsList className="h-8 bg-muted/50 border border-border/50 p-0.5 gap-1">
<TabsTrigger
value="code"
className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium transition-all [&[data-state=active]]:bg-white [&[data-state=active]]:dark:bg-primary/10 [&[data-state=active]]:text-foreground hover:bg-background/50 text-muted-foreground shadow-none"
>
<Code className="h-3.5 w-3.5" />
Source
</TabsTrigger>
<TabsTrigger
value="preview"
className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium transition-all [&[data-state=active]]:bg-white [&[data-state=active]]:dark:bg-primary/10 [&[data-state=active]]:text-foreground hover:bg-background/50 text-muted-foreground shadow-none"
>
<Eye className="h-3.5 w-3.5" />
Preview
</TabsTrigger>
</TabsList>
</div>
</div>
)}
</CardContent>
</CardHeader>
<CardContent className="p-0 -my-2 h-full flex-1 overflow-hidden relative">
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-screen w-full min-h-0">
{isStreaming && !updatedContent ? (
<LoadingState
icon={FileDiff}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
title="Applying File Edit"
filePath={processedFilePath || 'Processing file...'}
subtitle="Please wait while the file is being modified"
showProgress={false}
/>
) : shouldShowError ? (
<ErrorState message={errorMessage} />
) : (
renderSourceCode()
)}
</ScrollArea>
</TabsContent>
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-full w-full min-h-0">
{isStreaming && !updatedContent ? (
<LoadingState
icon={FileDiff}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
title="Applying File Edit"
filePath={processedFilePath || 'Processing file...'}
subtitle="Please wait while the file is being modified"
showProgress={false}
/>
) : shouldShowError ? (
<ErrorState message={errorMessage} />
) : (
renderFilePreview()
)}
{isStreaming && updatedContent && (
<div className="sticky bottom-4 right-4 float-right mr-4 mb-4">
<Badge className="bg-blue-500/90 text-white border-none shadow-lg animate-pulse">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
Streaming...
</Badge>
</div>
)}
</ScrollArea>
</TabsContent>
</CardContent>
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<Badge variant="outline" className="py-0.5 h-6">
<FileIcon className="h-3 w-3" />
{hasHighlighting ? language.toUpperCase() : fileExtension.toUpperCase() || 'TEXT'}
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{actualToolTimestamp && !isStreaming
? formatTimestamp(actualToolTimestamp)
: assistantTimestamp
? formatTimestamp(assistantTimestamp)
: ''}
</div>
</div>
</Tabs>
</Card>
);
}