2025-04-16 13:01:57 +08:00
|
|
|
"use client";
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
import { useState, useEffect, useRef, Fragment, useCallback } from "react";
|
2025-04-16 13:01:57 +08:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
import {
|
|
|
|
File,
|
|
|
|
Folder,
|
|
|
|
FolderOpen,
|
|
|
|
Upload,
|
|
|
|
Download,
|
|
|
|
ChevronRight,
|
|
|
|
Home,
|
2025-04-20 08:27:32 +08:00
|
|
|
ChevronLeft,
|
|
|
|
Loader,
|
|
|
|
AlertTriangle,
|
2025-04-16 13:01:57 +08:00
|
|
|
} from "lucide-react";
|
2025-04-16 15:16:38 +08:00
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
2025-04-20 08:27:32 +08:00
|
|
|
import { FileRenderer, getFileTypeFromExtension } from "@/components/file-renderers";
|
|
|
|
import { listSandboxFiles, getSandboxFileContent, type FileInfo, Project } from "@/lib/api";
|
|
|
|
import { toast } from "sonner";
|
|
|
|
import { createClient } from "@/lib/supabase/client";
|
2025-04-16 13:01:57 +08:00
|
|
|
|
|
|
|
// Define API_URL
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
|
|
|
|
|
|
|
interface FileViewerModalProps {
|
|
|
|
open: boolean;
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
sandboxId: string;
|
2025-04-19 01:30:09 +08:00
|
|
|
initialFilePath?: string | null;
|
2025-04-20 08:27:32 +08:00
|
|
|
project?: Project;
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
export function FileViewerModal({
|
|
|
|
open,
|
|
|
|
onOpenChange,
|
2025-04-19 01:30:09 +08:00
|
|
|
sandboxId,
|
2025-04-20 08:27:32 +08:00
|
|
|
initialFilePath,
|
|
|
|
project
|
2025-04-16 13:01:57 +08:00
|
|
|
}: FileViewerModalProps) {
|
2025-04-20 08:27:32 +08:00
|
|
|
// File navigation state
|
|
|
|
const [currentPath, setCurrentPath] = useState("/workspace");
|
|
|
|
const [files, setFiles] = useState<FileInfo[]>([]);
|
2025-04-16 13:01:57 +08:00
|
|
|
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
2025-04-20 08:27:32 +08:00
|
|
|
|
|
|
|
// File content state
|
|
|
|
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
|
|
|
const [rawContent, setRawContent] = useState<string | Blob | null>(null);
|
|
|
|
const [textContentForRenderer, setTextContentForRenderer] = useState<string | null>(null);
|
|
|
|
const [blobUrlForRenderer, setBlobUrlForRenderer] = useState<string | null>(null);
|
2025-04-16 13:01:57 +08:00
|
|
|
const [isLoadingContent, setIsLoadingContent] = useState(false);
|
2025-04-20 08:27:32 +08:00
|
|
|
const [contentError, setContentError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
// Add a ref to track current loading operation
|
|
|
|
const loadingFileRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
// Utility state
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
2025-04-16 13:01:57 +08:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// State to track if initial path has been processed
|
|
|
|
const [initialPathProcessed, setInitialPathProcessed] = useState(false);
|
|
|
|
|
|
|
|
// Project state
|
|
|
|
const [projectWithSandbox, setProjectWithSandbox] = useState<Project | undefined>(project);
|
|
|
|
|
|
|
|
// Setup project with sandbox URL if not provided directly
|
2025-04-16 13:01:57 +08:00
|
|
|
useEffect(() => {
|
2025-04-20 08:27:32 +08:00
|
|
|
if (project) {
|
|
|
|
setProjectWithSandbox(project);
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
}, [project, sandboxId]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Function to ensure a path starts with /workspace - Defined early
|
|
|
|
const normalizePath = useCallback((path: unknown): string => {
|
|
|
|
// Explicitly check if the path is a non-empty string
|
|
|
|
if (typeof path !== 'string' || !path) {
|
|
|
|
console.warn(`[FILE VIEWER] normalizePath received non-string or empty value:`, path, `Returning '/workspace'`);
|
|
|
|
return '/workspace';
|
2025-04-19 01:30:09 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
// Now we know path is a string
|
|
|
|
return path.startsWith('/workspace') ? path : `/workspace/${path.replace(/^\//, '')}`;
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
// Helper function to clear the selected file
|
|
|
|
const clearSelectedFile = useCallback(() => {
|
|
|
|
setSelectedFilePath(null);
|
|
|
|
setRawContent(null);
|
|
|
|
setTextContentForRenderer(null); // Clear derived text content
|
|
|
|
setBlobUrlForRenderer(null); // Clear derived blob URL
|
|
|
|
setContentError(null);
|
|
|
|
setIsLoadingContent(false);
|
|
|
|
loadingFileRef.current = null; // Clear the loading ref
|
|
|
|
}, []);
|
2025-04-19 01:30:09 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Helper function to navigate to a folder - COMPLETELY FIXED
|
|
|
|
const navigateToFolder = useCallback((folder: FileInfo) => {
|
|
|
|
if (!folder.is_dir) return;
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Ensure the path is properly normalized
|
|
|
|
const normalizedPath = normalizePath(folder.path);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Log before and after states for debugging
|
|
|
|
console.log(`[FILE VIEWER] Navigating to folder: ${folder.path} → ${normalizedPath}`);
|
|
|
|
console.log(`[FILE VIEWER] Current path before navigation: ${currentPath}`);
|
|
|
|
|
|
|
|
// Clear selected file when navigating
|
|
|
|
clearSelectedFile();
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Update path state - must happen after clearing selection
|
|
|
|
setCurrentPath(normalizedPath);
|
|
|
|
}, [normalizePath, clearSelectedFile, currentPath]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Navigate to a specific path in the breadcrumb
|
|
|
|
const navigateToBreadcrumb = useCallback((path: string) => {
|
|
|
|
const normalizedPath = normalizePath(path);
|
|
|
|
console.log(`[FILE VIEWER] Navigating to breadcrumb path: ${path} → ${normalizedPath}`);
|
|
|
|
clearSelectedFile();
|
|
|
|
setCurrentPath(normalizedPath);
|
|
|
|
}, [normalizePath, clearSelectedFile]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Helper function to navigate to home
|
|
|
|
const navigateHome = useCallback(() => {
|
|
|
|
console.log('[FILE VIEWER] Navigating home from:', currentPath);
|
|
|
|
clearSelectedFile();
|
|
|
|
setCurrentPath('/workspace');
|
|
|
|
}, [clearSelectedFile, currentPath]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Function to generate breadcrumb segments from a path
|
|
|
|
const getBreadcrumbSegments = useCallback((path: string) => {
|
|
|
|
// Ensure we're working with a normalized path
|
|
|
|
const normalizedPath = normalizePath(path);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Remove /workspace prefix and split by /
|
|
|
|
const cleanPath = normalizedPath.replace(/^\/workspace\/?/, '');
|
|
|
|
if (!cleanPath) return [];
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
const parts = cleanPath.split('/').filter(Boolean);
|
|
|
|
let currentPath = '/workspace';
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
return parts.map((part, index) => {
|
|
|
|
currentPath = `${currentPath}/${part}`;
|
|
|
|
return {
|
|
|
|
name: part,
|
|
|
|
path: currentPath,
|
|
|
|
isLast: index === parts.length - 1
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}, [normalizePath]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Core file opening function - Refined
|
|
|
|
const openFile = useCallback(async (file: FileInfo) => {
|
2025-04-16 13:01:57 +08:00
|
|
|
if (file.is_dir) {
|
2025-04-20 08:27:32 +08:00
|
|
|
navigateToFolder(file);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Skip if already selected and content exists
|
|
|
|
if (selectedFilePath === file.path && rawContent) {
|
|
|
|
console.log(`[FILE VIEWER] File already loaded: ${file.path}`);
|
2025-04-16 13:01:57 +08:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
console.log(`[FILE VIEWER] Opening file: ${file.path}`);
|
|
|
|
|
|
|
|
// Clear previous state FIRST
|
|
|
|
clearSelectedFile();
|
|
|
|
|
|
|
|
// Set loading state and selected file path immediately
|
2025-04-16 13:01:57 +08:00
|
|
|
setIsLoadingContent(true);
|
2025-04-20 08:27:32 +08:00
|
|
|
setSelectedFilePath(file.path);
|
|
|
|
|
|
|
|
// Set the loading ref to track current operation
|
|
|
|
loadingFileRef.current = file.path;
|
2025-04-16 13:01:57 +08:00
|
|
|
|
|
|
|
try {
|
2025-04-20 08:27:32 +08:00
|
|
|
// Fetch content
|
2025-04-16 13:01:57 +08:00
|
|
|
const content = await getSandboxFileContent(sandboxId, file.path);
|
2025-04-20 08:27:32 +08:00
|
|
|
console.log(`[FILE VIEWER] Received content for ${file.path} (${typeof content})`);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Critical check: Ensure the file we just loaded is still the one selected
|
|
|
|
if (loadingFileRef.current !== file.path) {
|
|
|
|
console.log(`[FILE VIEWER] Selection changed during loading, aborting. Loading: ${loadingFileRef.current}, Expected: ${file.path}`);
|
|
|
|
setIsLoadingContent(false); // Still need to stop loading indicator
|
|
|
|
return; // Abort state update
|
|
|
|
}
|
|
|
|
|
|
|
|
// Store raw content
|
|
|
|
setRawContent(content);
|
|
|
|
|
|
|
|
// Determine how to prepare content for the renderer
|
|
|
|
if (typeof content === 'string') {
|
|
|
|
console.log(`[FILE VIEWER] Setting text content directly for renderer.`);
|
|
|
|
setTextContentForRenderer(content);
|
|
|
|
setBlobUrlForRenderer(null); // Ensure no blob URL is set
|
|
|
|
} else if (content instanceof Blob) {
|
|
|
|
console.log(`[FILE VIEWER] Content is a Blob. Will generate URL if needed.`);
|
|
|
|
// Let the useEffect handle URL generation
|
|
|
|
setTextContentForRenderer(null); // Clear any previous text content
|
2025-04-16 13:01:57 +08:00
|
|
|
} else {
|
2025-04-20 08:27:32 +08:00
|
|
|
console.warn("[FILE VIEWER] Unexpected content type received.");
|
|
|
|
setContentError("Received unexpected content type.");
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
|
|
|
|
setIsLoadingContent(false);
|
2025-04-16 13:01:57 +08:00
|
|
|
} catch (error) {
|
2025-04-20 08:27:32 +08:00
|
|
|
console.error(`[FILE VIEWER] Error loading file:`, error);
|
|
|
|
|
|
|
|
// Only update error if this file is still the one being loaded
|
|
|
|
if (loadingFileRef.current === file.path) {
|
|
|
|
setContentError(`Failed to load file: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
setIsLoadingContent(false);
|
|
|
|
setRawContent(null); // Clear raw content on error
|
|
|
|
}
|
2025-04-16 13:01:57 +08:00
|
|
|
} finally {
|
2025-04-20 08:27:32 +08:00
|
|
|
// Clear the loading ref if it matches the current operation
|
|
|
|
if (loadingFileRef.current === file.path) {
|
|
|
|
loadingFileRef.current = null;
|
|
|
|
}
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
}, [sandboxId, selectedFilePath, rawContent, navigateToFolder, clearSelectedFile]);
|
|
|
|
|
|
|
|
// Effect to manage blob URL for renderer
|
2025-04-16 13:01:57 +08:00
|
|
|
useEffect(() => {
|
2025-04-20 08:27:32 +08:00
|
|
|
let objectUrl: string | null = null;
|
|
|
|
|
|
|
|
// Create a URL if rawContent is a Blob
|
|
|
|
if (rawContent instanceof Blob) {
|
|
|
|
// Determine if it *should* be text - might still render via blob URL if conversion fails
|
|
|
|
const fileType = selectedFilePath ? getFileTypeFromExtension(selectedFilePath) : 'binary';
|
|
|
|
const shouldBeText = ['text', 'code', 'markdown'].includes(fileType);
|
|
|
|
|
|
|
|
// Attempt to read as text first if it should be text
|
|
|
|
if (shouldBeText) {
|
|
|
|
rawContent.text()
|
|
|
|
.then(text => {
|
|
|
|
// Check if selection is still valid *before* setting state
|
|
|
|
if (loadingFileRef.current === null && selectedFilePath && rawContent instanceof Blob) {
|
|
|
|
console.log(`[FILE VIEWER] Successfully read Blob as text, length: ${text.length}`);
|
|
|
|
setTextContentForRenderer(text);
|
|
|
|
setBlobUrlForRenderer(null); // Clear any blob URL if text is successful
|
|
|
|
} else {
|
|
|
|
console.log("[FILE VIEWER] Selection changed or no longer a blob while reading text, discarding result.");
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.catch(err => {
|
|
|
|
console.warn("[FILE VIEWER] Failed to read Blob as text, falling back to blob URL:", err);
|
|
|
|
// If reading as text fails, fall back to creating a blob URL
|
|
|
|
if (loadingFileRef.current === null && selectedFilePath && rawContent instanceof Blob) {
|
|
|
|
objectUrl = URL.createObjectURL(rawContent);
|
|
|
|
console.log(`[FILE VIEWER] Created blob URL (fallback): ${objectUrl}`);
|
|
|
|
setBlobUrlForRenderer(objectUrl);
|
|
|
|
setTextContentForRenderer(null); // Ensure text content is cleared
|
|
|
|
} else {
|
|
|
|
console.log("[FILE VIEWER] Selection changed or no longer a blob during text read fallback, discarding result.");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
// For binary types, directly create the blob URL
|
|
|
|
objectUrl = URL.createObjectURL(rawContent);
|
|
|
|
console.log(`[FILE VIEWER] Created blob URL for binary type: ${objectUrl}`);
|
|
|
|
setBlobUrlForRenderer(objectUrl);
|
|
|
|
setTextContentForRenderer(null);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// If rawContent is not a Blob, ensure URL state is null
|
|
|
|
setBlobUrlForRenderer(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Cleanup function to revoke the URL
|
2025-04-16 13:01:57 +08:00
|
|
|
return () => {
|
2025-04-20 08:27:32 +08:00
|
|
|
if (objectUrl) {
|
|
|
|
console.log(`[FILE VIEWER] Revoking blob URL: ${objectUrl}`);
|
|
|
|
URL.revokeObjectURL(objectUrl);
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
|
|
|
};
|
2025-04-20 08:27:32 +08:00
|
|
|
}, [rawContent, selectedFilePath]); // Re-run when rawContent or selectedFilePath changes
|
|
|
|
|
|
|
|
// Handle file download - Define after helpers
|
|
|
|
const handleDownload = useCallback(async () => {
|
|
|
|
if (!selectedFilePath || isDownloading) return;
|
|
|
|
|
|
|
|
setIsDownloading(true);
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Use cached content if available
|
|
|
|
if (rawContent) {
|
|
|
|
const blob = rawContent instanceof Blob
|
|
|
|
? rawContent
|
|
|
|
: new Blob([rawContent], { type: 'text/plain' });
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
|
|
a.href = url;
|
|
|
|
a.download = selectedFilePath.split('/').pop() || 'file';
|
|
|
|
document.body.appendChild(a);
|
|
|
|
a.click();
|
|
|
|
document.body.removeChild(a);
|
|
|
|
URL.revokeObjectURL(url); // Clean up the URL
|
|
|
|
|
|
|
|
toast.success("File downloaded");
|
|
|
|
} else {
|
|
|
|
// Fetch directly if not cached
|
|
|
|
const content = await getSandboxFileContent(sandboxId, selectedFilePath);
|
|
|
|
const blob = content instanceof Blob ? content : new Blob([String(content)]);
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
|
|
a.href = url;
|
|
|
|
a.download = selectedFilePath.split('/').pop() || 'file';
|
|
|
|
document.body.appendChild(a);
|
|
|
|
a.click();
|
|
|
|
document.body.removeChild(a);
|
|
|
|
URL.revokeObjectURL(url); // Clean up the URL
|
|
|
|
|
|
|
|
toast.success("File downloaded");
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Download failed:", error);
|
|
|
|
toast.error("Failed to download file");
|
|
|
|
} finally {
|
|
|
|
setIsDownloading(false);
|
|
|
|
}
|
|
|
|
}, [selectedFilePath, isDownloading, rawContent, sandboxId]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Handle file upload - Define after helpers
|
|
|
|
const handleUpload = useCallback(() => {
|
2025-04-16 13:01:57 +08:00
|
|
|
if (fileInputRef.current) {
|
|
|
|
fileInputRef.current.click();
|
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
// Process uploaded file - Define after helpers
|
|
|
|
const processUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
if (!event.target.files || event.target.files.length === 0) return;
|
|
|
|
|
|
|
|
const file = event.target.files[0];
|
|
|
|
setIsUploading(true);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
|
|
|
try {
|
|
|
|
const formData = new FormData();
|
|
|
|
formData.append('file', file);
|
|
|
|
formData.append('path', `${currentPath}/${file.name}`);
|
|
|
|
|
|
|
|
const supabase = createClient();
|
|
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
|
|
|
|
|
|
if (!session?.access_token) {
|
|
|
|
throw new Error('No access token available');
|
|
|
|
}
|
|
|
|
|
|
|
|
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
|
|
|
|
method: 'POST',
|
|
|
|
headers: {
|
|
|
|
'Authorization': `Bearer ${session.access_token}`,
|
|
|
|
},
|
|
|
|
body: formData
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2025-04-20 08:27:32 +08:00
|
|
|
const error = await response.text();
|
|
|
|
throw new Error(error || 'Upload failed');
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Reload the file list
|
|
|
|
const filesData = await listSandboxFiles(sandboxId, currentPath);
|
|
|
|
setFiles(filesData);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
toast.success(`Uploaded: ${file.name}`);
|
2025-04-16 13:01:57 +08:00
|
|
|
} catch (error) {
|
2025-04-20 08:27:32 +08:00
|
|
|
console.error("Upload failed:", error);
|
|
|
|
toast.error(`Upload failed: ${error instanceof Error ? error.message : String(error)}`);
|
2025-04-16 13:01:57 +08:00
|
|
|
} finally {
|
2025-04-20 08:27:32 +08:00
|
|
|
setIsUploading(false);
|
|
|
|
if (event.target) event.target.value = '';
|
2025-04-16 13:01:57 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
}, [currentPath, sandboxId]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Handle modal closing - clean up resources
|
|
|
|
const handleOpenChange = useCallback((open: boolean) => {
|
|
|
|
if (!open) {
|
|
|
|
console.log('[FILE VIEWER] handleOpenChange: Modal closing, resetting state.');
|
|
|
|
clearSelectedFile();
|
|
|
|
setCurrentPath('/workspace'); // Reset path to root
|
|
|
|
setFiles([]);
|
|
|
|
setInitialPathProcessed(false); // Reset the processed flag
|
|
|
|
}
|
|
|
|
onOpenChange(open);
|
|
|
|
}, [onOpenChange, clearSelectedFile]);
|
2025-04-16 13:01:57 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// --- useEffect Hooks --- //
|
|
|
|
|
|
|
|
// Load files when modal opens or path changes - Refined
|
|
|
|
useEffect(() => {
|
|
|
|
if (!open || !sandboxId) {
|
|
|
|
return; // Don't load if modal is closed or no sandbox ID
|
|
|
|
}
|
2025-04-16 15:16:38 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
const loadFiles = async () => {
|
|
|
|
setIsLoadingFiles(true);
|
|
|
|
console.log(`[FILE VIEWER] useEffect[currentPath]: Triggered. Loading files for path: ${currentPath}`);
|
|
|
|
try {
|
|
|
|
const filesData = await listSandboxFiles(sandboxId, currentPath);
|
|
|
|
console.log(`[FILE VIEWER] useEffect[currentPath]: API returned ${filesData.length} files.`);
|
|
|
|
setFiles(filesData);
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Failed to load files:", error);
|
|
|
|
toast.error("Failed to load files");
|
|
|
|
setFiles([]);
|
|
|
|
} finally {
|
|
|
|
setIsLoadingFiles(false);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
loadFiles();
|
|
|
|
// Dependency: Only re-run when open, sandboxId, or currentPath changes
|
|
|
|
}, [open, sandboxId, currentPath]);
|
|
|
|
|
|
|
|
// Handle initial file path - Runs ONLY ONCE on open if initialFilePath is provided
|
|
|
|
useEffect(() => {
|
|
|
|
// Only run if modal is open, initial path is provided, AND it hasn't been processed yet
|
|
|
|
if (open && initialFilePath && !initialPathProcessed) {
|
|
|
|
console.log(`[FILE VIEWER] useEffect[initialFilePath]: Processing initial path: ${initialFilePath}`);
|
2025-04-16 15:16:38 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Normalize the initial path
|
|
|
|
const fullPath = normalizePath(initialFilePath);
|
|
|
|
const lastSlashIndex = fullPath.lastIndexOf('/');
|
|
|
|
const directoryPath = lastSlashIndex > 0 ? fullPath.substring(0, lastSlashIndex) : '/workspace';
|
|
|
|
const fileName = lastSlashIndex >= 0 ? fullPath.substring(lastSlashIndex + 1) : '';
|
|
|
|
|
|
|
|
console.log(`[FILE VIEWER] useEffect[initialFilePath]: Normalized Path: ${fullPath}, Directory: ${directoryPath}, File: ${fileName}`);
|
|
|
|
|
|
|
|
// Set the current path to the target directory
|
|
|
|
// This will trigger the other useEffect to load files for this directory
|
|
|
|
if (currentPath !== directoryPath) {
|
|
|
|
console.log(`[FILE VIEWER] useEffect[initialFilePath]: Setting current path to ${directoryPath}`);
|
|
|
|
setCurrentPath(directoryPath);
|
2025-04-16 15:16:38 +08:00
|
|
|
}
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// Mark the initial path as processed so this doesn't run again
|
|
|
|
setInitialPathProcessed(true);
|
2025-04-16 15:16:38 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// We don't need to open the file here; the file loading useEffect
|
|
|
|
// combined with the logic below will handle it once files are loaded.
|
|
|
|
|
|
|
|
} else if (!open) {
|
|
|
|
// Reset the processed flag when the modal closes
|
|
|
|
console.log('[FILE VIEWER] useEffect[initialFilePath]: Modal closed, resetting initialPathProcessed flag.');
|
|
|
|
setInitialPathProcessed(false);
|
|
|
|
}
|
|
|
|
}, [open, initialFilePath, initialPathProcessed, normalizePath, currentPath]); // Dependencies carefully chosen
|
|
|
|
|
|
|
|
// Effect to open the initial file *after* the correct directory files are loaded
|
|
|
|
useEffect(() => {
|
|
|
|
// Only run if initial path was processed, files are loaded, and no file is currently selected
|
|
|
|
if (initialPathProcessed && !isLoadingFiles && files.length > 0 && !selectedFilePath && initialFilePath) {
|
|
|
|
console.log('[FILE VIEWER] useEffect[openInitialFile]: Checking for initial file now that files are loaded.');
|
|
|
|
|
|
|
|
const fullPath = normalizePath(initialFilePath);
|
|
|
|
const lastSlashIndex = fullPath.lastIndexOf('/');
|
|
|
|
const targetFileName = lastSlashIndex >= 0 ? fullPath.substring(lastSlashIndex + 1) : '';
|
|
|
|
|
|
|
|
if (targetFileName) {
|
|
|
|
console.log(`[FILE VIEWER] useEffect[openInitialFile]: Looking for file: ${targetFileName} in current directory: ${currentPath}`);
|
|
|
|
const targetFile = files.find(f => f.name === targetFileName && f.path === fullPath);
|
|
|
|
|
|
|
|
if (targetFile && !targetFile.is_dir) {
|
|
|
|
console.log(`[FILE VIEWER] useEffect[openInitialFile]: Found initial file, opening: ${targetFile.path}`);
|
|
|
|
openFile(targetFile);
|
|
|
|
} else {
|
|
|
|
console.log(`[FILE VIEWER] useEffect[openInitialFile]: Initial file ${targetFileName} not found in loaded files or is a directory.`);
|
|
|
|
}
|
|
|
|
}
|
2025-04-16 15:16:38 +08:00
|
|
|
}
|
2025-04-20 08:27:32 +08:00
|
|
|
}, [initialPathProcessed, isLoadingFiles, files, selectedFilePath, initialFilePath, normalizePath, currentPath, openFile]); // Depends on files being loaded
|
2025-04-16 15:16:38 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
// --- Render --- //
|
2025-04-16 13:01:57 +08:00
|
|
|
return (
|
2025-04-20 08:27:32 +08:00
|
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
|
|
<DialogContent className="sm:max-w-[90vw] md:max-w-[1200px] w-[95vw] h-[90vh] max-h-[900px] flex flex-col p-0 gap-0 overflow-hidden">
|
|
|
|
<DialogHeader className="px-4 py-2 border-b flex-shrink-0">
|
2025-04-16 15:16:38 +08:00
|
|
|
<DialogTitle className="text-lg font-semibold">Workspace Files</DialogTitle>
|
2025-04-16 13:01:57 +08:00
|
|
|
</DialogHeader>
|
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
{/* Navigation Bar */}
|
|
|
|
<div className="px-4 py-2 border-b flex items-center gap-2">
|
|
|
|
<Button
|
|
|
|
variant="ghost"
|
|
|
|
size="icon"
|
|
|
|
onClick={navigateHome}
|
|
|
|
className="h-8 w-8"
|
|
|
|
title="Go to home directory"
|
|
|
|
>
|
|
|
|
<Home className="h-4 w-4" />
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
<div className="flex items-center overflow-x-auto flex-1 min-w-0 scrollbar-hide whitespace-nowrap">
|
|
|
|
<Button
|
|
|
|
variant="ghost"
|
|
|
|
size="sm"
|
|
|
|
className="h-7 px-2 text-sm font-medium min-w-fit flex-shrink-0"
|
|
|
|
onClick={navigateHome}
|
|
|
|
>
|
|
|
|
home
|
|
|
|
</Button>
|
2025-04-16 15:16:38 +08:00
|
|
|
|
2025-04-20 08:27:32 +08:00
|
|
|
{currentPath !== '/workspace' && (
|
|
|
|
<>
|
|
|
|
{getBreadcrumbSegments(currentPath).map((segment, index) => (
|
|
|
|
<Fragment key={segment.path}>
|
|
|
|
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground opacity-50 flex-shrink-0" />
|
|
|
|
<Button
|
|
|
|
variant="ghost"
|
|
|
|
size="sm"
|
|
|
|
className="h-7 px-2 text-sm font-medium truncate max-w-[200px]"
|
|
|
|
onClick={() => navigateToBreadcrumb(segment.path)}
|
2025-04-16 15:16:38 +08:00
|
|
|
>
|
2025-04-20 08:27:32 +08:00
|
|
|
{segment.name}
|
|
|
|
</Button>
|
|
|
|
</Fragment>
|
|
|
|
))}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{selectedFilePath && (
|
|
|
|
<>
|
|
|
|
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground opacity-50 flex-shrink-0" />
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
<span className="text-sm font-medium truncate">
|
|
|
|
{selectedFilePath.split('/').pop()}
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
{selectedFilePath && (
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
onClick={handleDownload}
|
|
|
|
disabled={isDownloading || isLoadingContent}
|
|
|
|
className="h-8 gap-1"
|
|
|
|
>
|
|
|
|
{isDownloading ? (
|
|
|
|
<Loader className="h-4 w-4 animate-spin" />
|
|
|
|
) : (
|
|
|
|
<Download className="h-4 w-4" />
|
|
|
|
)}
|
|
|
|
<span className="hidden sm:inline">Download</span>
|
|
|
|
</Button>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{!selectedFilePath && (
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
onClick={handleUpload}
|
|
|
|
disabled={isUploading}
|
|
|
|
className="h-8 gap-1"
|
|
|
|
>
|
|
|
|
{isUploading ? (
|
|
|
|
<Loader className="h-4 w-4 animate-spin" />
|
|
|
|
) : (
|
|
|
|
<Upload className="h-4 w-4" />
|
|
|
|
)}
|
|
|
|
<span className="hidden sm:inline">Upload</span>
|
|
|
|
</Button>
|
|
|
|
)}
|
|
|
|
|
|
|
|
<input
|
|
|
|
type="file"
|
|
|
|
ref={fileInputRef}
|
|
|
|
className="hidden"
|
|
|
|
onChange={processUpload}
|
|
|
|
disabled={isUploading}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* Content Area */}
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
|
|
{selectedFilePath ? (
|
|
|
|
/* File Viewer */
|
|
|
|
<div className="h-full w-full overflow-auto">
|
|
|
|
{isLoadingContent ? (
|
|
|
|
<div className="h-full w-full flex flex-col items-center justify-center">
|
|
|
|
<Loader className="h-8 w-8 animate-spin text-primary mb-3" />
|
|
|
|
<p className="text-sm text-muted-foreground">Loading file...</p>
|
|
|
|
</div>
|
|
|
|
) : contentError ? (
|
|
|
|
<div className="h-full w-full flex items-center justify-center p-4">
|
|
|
|
<div className="max-w-md p-6 text-center border rounded-lg bg-muted/10">
|
|
|
|
<AlertTriangle className="h-10 w-10 text-orange-500 mx-auto mb-4" />
|
|
|
|
<h3 className="text-lg font-medium mb-2">Error Loading File</h3>
|
|
|
|
<p className="text-sm text-muted-foreground mb-4">{contentError}</p>
|
|
|
|
<div className="flex justify-center gap-3">
|
|
|
|
<Button
|
|
|
|
onClick={() => {
|
|
|
|
setContentError(null);
|
|
|
|
setIsLoadingContent(true);
|
|
|
|
openFile({
|
|
|
|
path: selectedFilePath,
|
|
|
|
name: selectedFilePath.split('/').pop() || '',
|
|
|
|
is_dir: false,
|
|
|
|
size: 0,
|
|
|
|
mod_time: new Date().toISOString()
|
|
|
|
} as FileInfo);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Retry
|
|
|
|
</Button>
|
2025-04-16 15:50:32 +08:00
|
|
|
<Button
|
2025-04-20 08:27:32 +08:00
|
|
|
variant="outline"
|
|
|
|
onClick={() => {
|
|
|
|
clearSelectedFile();
|
|
|
|
}}
|
2025-04-16 15:50:32 +08:00
|
|
|
>
|
2025-04-20 08:27:32 +08:00
|
|
|
Back to Files
|
2025-04-16 15:50:32 +08:00
|
|
|
</Button>
|
|
|
|
</div>
|
2025-04-20 08:27:32 +08:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
<div className="h-full w-full relative">
|
|
|
|
<FileRenderer
|
|
|
|
key={selectedFilePath}
|
|
|
|
content={textContentForRenderer}
|
|
|
|
binaryUrl={blobUrlForRenderer}
|
|
|
|
fileName={selectedFilePath}
|
|
|
|
className="h-full w-full"
|
|
|
|
project={projectWithSandbox}
|
|
|
|
/>
|
|
|
|
<div className="absolute top-3 right-3">
|
|
|
|
<Button
|
|
|
|
variant="outline"
|
|
|
|
size="sm"
|
|
|
|
className="h-8 w-8 p-0 rounded-full bg-background/80 shadow-md hover:bg-background"
|
|
|
|
title="Back to files"
|
|
|
|
onClick={() => clearSelectedFile()}
|
|
|
|
>
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
2025-04-16 13:01:57 +08:00
|
|
|
</div>
|
2025-04-20 08:27:32 +08:00
|
|
|
) : (
|
|
|
|
/* File Explorer */
|
|
|
|
<div className="h-full w-full">
|
|
|
|
{isLoadingFiles ? (
|
|
|
|
<div className="h-full w-full flex items-center justify-center">
|
|
|
|
<Loader className="h-6 w-6 animate-spin text-primary" />
|
2025-04-16 15:16:38 +08:00
|
|
|
</div>
|
2025-04-20 08:27:32 +08:00
|
|
|
) : files.length === 0 ? (
|
|
|
|
<div className="h-full w-full flex flex-col items-center justify-center">
|
|
|
|
<Folder className="h-12 w-12 mb-2 text-muted-foreground opacity-30" />
|
|
|
|
<p className="text-sm text-muted-foreground">Directory is empty</p>
|
2025-04-16 16:19:18 +08:00
|
|
|
</div>
|
|
|
|
) : (
|
2025-04-20 08:27:32 +08:00
|
|
|
<ScrollArea className="h-full w-full p-2">
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 p-4">
|
|
|
|
{files.map(file => (
|
|
|
|
<button
|
|
|
|
key={file.path}
|
|
|
|
className={`flex flex-col items-center p-3 rounded-lg border hover:bg-muted/50 transition-colors ${
|
|
|
|
selectedFilePath === file.path ? 'bg-muted border-primary/20' : ''
|
|
|
|
}`}
|
|
|
|
onClick={() => {
|
|
|
|
if (file.is_dir) {
|
|
|
|
console.log(`[FILE VIEWER] Folder clicked: ${file.name}, path: ${file.path}`);
|
|
|
|
navigateToFolder(file);
|
|
|
|
} else {
|
|
|
|
openFile(file);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div className="w-12 h-12 flex items-center justify-center mb-1">
|
|
|
|
{file.is_dir ? (
|
|
|
|
<Folder className="h-9 w-9 text-blue-500" />
|
|
|
|
) : (
|
|
|
|
<File className="h-8 w-8 text-muted-foreground" />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<span className="text-xs text-center font-medium truncate max-w-full">
|
|
|
|
{file.name}
|
|
|
|
</span>
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</ScrollArea>
|
2025-04-16 16:19:18 +08:00
|
|
|
)}
|
2025-04-16 13:01:57 +08:00
|
|
|
</div>
|
2025-04-20 08:27:32 +08:00
|
|
|
)}
|
2025-04-16 13:01:57 +08:00
|
|
|
</div>
|
|
|
|
</DialogContent>
|
|
|
|
</Dialog>
|
|
|
|
);
|
|
|
|
}
|