Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { | |
| Save, | |
| FileText, | |
| RotateCcw, | |
| AlertCircle, | |
| CheckCircle, | |
| Search, | |
| Hash, | |
| } from "lucide-react"; | |
| import { Trace } from "@/types"; | |
| interface TraceEditorProps { | |
| trace: Trace; | |
| onBack?: () => void; | |
| } | |
| interface LineNumberMapping { | |
| [lineNumber: number]: number; // line number to character position mapping | |
| } | |
| export function TraceEditor({ trace, onBack: _onBack }: TraceEditorProps) { | |
| const [content, setContent] = useState(""); | |
| const [originalContent, setOriginalContent] = useState(""); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [saveSuccess, setSaveSuccess] = useState(false); | |
| const [validationError, setValidationError] = useState<string | null>(null); | |
| const [lineMapping, setLineMapping] = useState<LineNumberMapping>({}); | |
| const [gotoLineNumber, setGotoLineNumber] = useState(""); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| // JSON validation function | |
| const validateJSON = ( | |
| content: string | |
| ): { isValid: boolean; error?: string } => { | |
| if (!content.trim()) { | |
| return { isValid: false, error: "Content cannot be empty" }; | |
| } | |
| try { | |
| JSON.parse(content); | |
| return { isValid: true }; | |
| } catch (e) { | |
| if (e instanceof SyntaxError) { | |
| return { | |
| isValid: false, | |
| error: `Invalid JSON: ${e.message}`, | |
| }; | |
| } | |
| return { | |
| isValid: false, | |
| error: "Invalid JSON format", | |
| }; | |
| } | |
| }; | |
| // Validate content on change | |
| useEffect(() => { | |
| if (content !== originalContent) { | |
| const validation = validateJSON(content); | |
| setValidationError(validation.isValid ? null : validation.error || null); | |
| } else { | |
| setValidationError(null); | |
| } | |
| }, [content, originalContent]); | |
| // Create line number mapping (similar to TraceLineNumberProcessor) | |
| const createLineMapping = (content: string): LineNumberMapping => { | |
| const lines = content.split("\n"); | |
| const mapping: LineNumberMapping = {}; | |
| let currentCharPos = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| const lineNumber = i + 1; | |
| const line = lines[i]; | |
| mapping[lineNumber] = currentCharPos; | |
| currentCharPos += (line?.length || 0) + 1; // +1 for newline character | |
| } | |
| return mapping; | |
| }; | |
| // Get line mapping for external use (e.g., ContentReference integration) | |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| const getLineMapping = (): LineNumberMapping => { | |
| return lineMapping; | |
| }; | |
| // Load trace content with line numbers for ContentReference compatibility | |
| useEffect(() => { | |
| const loadTraceContent = async () => { | |
| try { | |
| setIsLoading(true); | |
| setError(null); | |
| const response = await fetch( | |
| `/api/traces/${trace.trace_id}/content-numbered` | |
| ); | |
| if (!response.ok) { | |
| throw new Error( | |
| `Failed to load trace content: ${response.statusText}` | |
| ); | |
| } | |
| const data = await response.json(); | |
| const traceContent = data.content || ""; | |
| setContent(traceContent); | |
| setOriginalContent(traceContent); | |
| // Create line number mapping | |
| const mapping = createLineMapping(traceContent); | |
| setLineMapping(mapping); | |
| } catch (err) { | |
| setError( | |
| err instanceof Error ? err.message : "Failed to load trace content" | |
| ); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| loadTraceContent(); | |
| }, [trace.trace_id]); | |
| // Calculate statistics | |
| const characterCount = content.length; | |
| const lineCount = content.split("\n").length; | |
| const wordCount = | |
| content.trim() === "" ? 0 : content.trim().split(/\s+/).length; | |
| const hasChanges = content !== originalContent; | |
| const canSave = hasChanges && !validationError; | |
| // Handle save | |
| const handleSave = async () => { | |
| // Prevent saving if there are validation errors | |
| if (validationError) { | |
| setError("Cannot save: " + validationError); | |
| return; | |
| } | |
| try { | |
| setIsSaving(true); | |
| setError(null); | |
| setSaveSuccess(false); | |
| const response = await fetch(`/api/traces/${trace.trace_id}/content`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ content }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to save trace: ${response.statusText}`); | |
| } | |
| setOriginalContent(content); | |
| setSaveSuccess(true); | |
| // Update line mapping for the new content | |
| const mapping = createLineMapping(content); | |
| setLineMapping(mapping); | |
| // Auto-hide success message after 3 seconds | |
| setTimeout(() => setSaveSuccess(false), 3000); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Failed to save trace"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| // Handle reset | |
| const handleReset = () => { | |
| if (window.confirm("Are you sure you want to discard all changes?")) { | |
| setContent(originalContent); | |
| // Reset line mapping | |
| const mapping = createLineMapping(originalContent); | |
| setLineMapping(mapping); | |
| } | |
| }; | |
| // Handle goto line | |
| const handleGotoLine = () => { | |
| const lineNumber = parseInt(gotoLineNumber); | |
| if ( | |
| lineNumber && | |
| lineNumber > 0 && | |
| lineNumber <= lineCount && | |
| textareaRef.current | |
| ) { | |
| const lines = content.split("\n"); | |
| let position = 0; | |
| for (let i = 0; i < lineNumber - 1; i++) { | |
| position += (lines[i]?.length || 0) + 1; // +1 for newline | |
| } | |
| textareaRef.current.focus(); | |
| textareaRef.current.setSelectionRange(position, position); | |
| textareaRef.current.scrollTop = (lineNumber - 1) * 20; // Approximate line height | |
| } | |
| }; | |
| // Handle content change | |
| const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| setContent(e.target.value); | |
| }; | |
| // Handle scroll synchronization between line numbers and textarea | |
| const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => { | |
| const lineNumbersElement = document.getElementById("line-numbers"); | |
| if (lineNumbersElement && e.currentTarget) { | |
| const scrollTop = e.currentTarget.scrollTop; | |
| lineNumbersElement.style.transform = `translateY(-${scrollTop}px)`; | |
| } | |
| }; | |
| // Generate line numbers based on content | |
| const generateLineNumbers = () => { | |
| const lines = content.split("\n"); | |
| return lines.map((_, index) => `${index + 1}`).join("\n"); | |
| }; | |
| // Expose line mapping functionality for future ContentReference integration | |
| React.useEffect(() => { | |
| if (Object.keys(lineMapping).length > 0) { | |
| console.debug( | |
| "Line mapping updated:", | |
| Object.keys(lineMapping).length, | |
| "lines" | |
| ); | |
| } | |
| }, [lineMapping]); | |
| return ( | |
| <div className="p-6 space-y-6"> | |
| <style>{` | |
| #line-numbers { | |
| scrollbar-width: none; | |
| -ms-overflow-style: none; | |
| } | |
| #line-numbers::-webkit-scrollbar { | |
| display: none; | |
| } | |
| `}</style> | |
| {/* Action Buttons */} | |
| <div className="flex items-center justify-end gap-2"> | |
| {hasChanges && ( | |
| <Button variant="outline" onClick={handleReset}> | |
| <RotateCcw className="h-4 w-4 mr-2" /> | |
| Reset | |
| </Button> | |
| )} | |
| <Button | |
| onClick={handleSave} | |
| disabled={!canSave || isSaving} | |
| className="bg-primary hover:bg-primary/90" | |
| > | |
| {isSaving ? ( | |
| <> | |
| <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" /> | |
| Saving... | |
| </> | |
| ) : ( | |
| <> | |
| <Save className="h-4 w-4 mr-2" /> | |
| Save Changes | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| {/* Status Messages */} | |
| {error && ( | |
| <div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3"> | |
| <div className="flex items-center gap-2 text-destructive"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <span className="text-sm font-medium">{error}</span> | |
| </div> | |
| </div> | |
| )} | |
| {validationError && ( | |
| <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> | |
| <div className="flex items-center gap-2 text-yellow-800"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <span className="text-sm font-medium"> | |
| Validation Error: {validationError} | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| {saveSuccess && ( | |
| <div className="bg-green-50 border border-green-200 rounded-lg p-3"> | |
| <div className="flex items-center gap-2 text-green-800"> | |
| <CheckCircle className="h-4 w-4" /> | |
| <div> | |
| <span className="text-sm font-medium"> | |
| Trace content saved successfully! | |
| </span> | |
| <p className="text-xs text-green-700 mt-1"> | |
| Note: Trace statistics may need to be regenerated to reflect | |
| your changes. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* File Info & Statistics */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| <FileText className="h-5 w-5" /> | |
| File Information | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| <div> | |
| <div className="text-sm text-muted-foreground mb-1"> | |
| Characters | |
| </div> | |
| <div className="text-lg font-medium"> | |
| {characterCount.toLocaleString()} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-sm text-muted-foreground mb-1">Lines</div> | |
| <div className="text-lg font-medium"> | |
| {lineCount.toLocaleString()} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-sm text-muted-foreground mb-1">Words</div> | |
| <div className="text-lg font-medium"> | |
| {wordCount.toLocaleString()} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-sm text-muted-foreground mb-1">Status</div> | |
| <div className="flex items-center gap-2"> | |
| {hasChanges ? ( | |
| <Badge variant="secondary">Modified</Badge> | |
| ) : ( | |
| <Badge variant="outline">Saved</Badge> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Editor Controls */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Search className="h-5 w-5" /> | |
| Editor Controls | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="flex items-center gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <Label htmlFor="goto-line"> | |
| <Hash className="h-4 w-4" /> | |
| </Label> | |
| <Input | |
| id="goto-line" | |
| type="number" | |
| placeholder="Line number" | |
| value={gotoLineNumber} | |
| onChange={(e) => setGotoLineNumber(e.target.value)} | |
| className="w-32" | |
| min="1" | |
| max={lineCount} | |
| /> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleGotoLine} | |
| disabled={!gotoLineNumber || isLoading} | |
| > | |
| Go to Line | |
| </Button> | |
| </div> | |
| <div className="text-sm text-muted-foreground"> | |
| Language: plaintext | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Simple Text Editor */} | |
| <Card className="flex-1"> | |
| <CardHeader> | |
| <CardTitle>Content Editor</CardTitle> | |
| </CardHeader> | |
| <CardContent className="p-0"> | |
| {isLoading ? ( | |
| <div className="space-y-4 p-6"> | |
| <div className="h-8 w-full bg-muted animate-pulse rounded" /> | |
| <div className="h-6 w-3/4 bg-muted animate-pulse rounded" /> | |
| <div className="h-6 w-1/2 bg-muted animate-pulse rounded" /> | |
| </div> | |
| ) : ( | |
| <div className="p-4"> | |
| <div | |
| className="flex border rounded-lg overflow-hidden relative" | |
| style={{ border: "1px solid hsl(var(--border))" }} | |
| > | |
| {/* Line Numbers Container */} | |
| <div | |
| className="bg-muted/30 relative overflow-hidden" | |
| style={{ | |
| minWidth: "60px", | |
| maxWidth: "60px", | |
| height: "600px", | |
| borderRight: "1px solid hsl(var(--border))", | |
| }} | |
| > | |
| <div | |
| id="line-numbers" | |
| className="text-muted-foreground text-right select-none" | |
| style={{ | |
| fontFamily: | |
| "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace", | |
| fontSize: "14px", | |
| lineHeight: "20px", | |
| padding: "16px 8px 16px 16px", | |
| whiteSpace: "pre", | |
| willChange: "transform", | |
| }} | |
| > | |
| {generateLineNumbers()} | |
| </div> | |
| </div> | |
| {/* Text Editor */} | |
| <textarea | |
| ref={textareaRef} | |
| value={content} | |
| onChange={handleContentChange} | |
| onScroll={handleScroll} | |
| className="flex-1 h-[600px] resize-none focus:outline-none bg-transparent border-none" | |
| style={{ | |
| fontFamily: | |
| "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace", | |
| fontSize: "14px", | |
| lineHeight: "20px", | |
| backgroundColor: "hsl(var(--background))", | |
| color: "hsl(var(--foreground))", | |
| padding: "16px", | |
| margin: 0, | |
| outline: "none", | |
| }} | |
| spellCheck={false} | |
| autoComplete="off" | |
| autoCorrect="off" | |
| autoCapitalize="off" | |
| wrap="soft" | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } | |