import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save, Trash2, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { api } from "@/utils/api"; import { CreateFileDialog } from "./create-file-dialog"; interface Props { id: string; type: "application" | "compose"; repoPath: string; onClose: () => void; } type DirectoryEntry = { name: string; path: string; type: "file" | "directory"; children?: DirectoryEntry[]; }; export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(""); const [createFolderPath, setCreateFolderPath] = useState(null); const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); const utils = api.useUtils(); const { data: directories, isPending: isDirLoading } = api.patch.readRepoDirectories.useQuery( { id: id, type, repoPath }, { enabled: !!repoPath }, ); const { data: patches } = api.patch.byEntityId.useQuery( { id, type }, { enabled: !!id }, ); const { mutateAsync: saveAsPatch, isPending: isSavingPatch } = api.patch.saveFileAsPatch.useMutation(); const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } = api.patch.markFileForDeletion.useMutation(); const updatePatch = api.patch.update.useMutation(); const { data: fileData, isFetching: isFileLoading } = api.patch.readRepoFile.useQuery( { id, type, filePath: selectedFile || "", }, { enabled: !!selectedFile, }, ); useEffect(() => { if (fileData !== undefined) { setFileContent(fileData); } }, [fileData]); const handleFileSelect = (filePath: string) => { setSelectedFile(filePath); }; const toggleFolder = (path: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }; const handleSave = () => { if (!selectedFile) return; saveAsPatch({ id, type, filePath: selectedFile, content: fileContent, patchType: "update", }) .then(() => { toast.success("Patch saved"); utils.patch.byEntityId.invalidate({ id, type }); }) .catch(() => { toast.error("Failed to save patch"); }); }; const handleMarkForDeletion = () => { if (!selectedFile) return; markForDeletion({ id, type, filePath: selectedFile }) .then(() => { toast.success("File marked for deletion"); utils.patch.byEntityId.invalidate({ id, type }); }) .catch(() => { toast.error("Failed to mark file for deletion"); }); }; const handleCreateFile = useCallback( (folderPath: string, filename: string, content: string) => { const filePath = folderPath ? `${folderPath}/${filename}` : filename; saveAsPatch({ id, type, filePath, content, patchType: "create", }) .then(() => { toast.success("File created"); utils.patch.byEntityId.invalidate({ id, type }); }) .catch(() => { toast.error("Failed to create file"); }); }, [id, type, saveAsPatch, utils], ); const selectedFilePatch = patches?.find( (p) => p.filePath === selectedFile && p.type === "delete", ); const handleUnmarkDeletion = () => { if (!selectedFilePatch) return; updatePatch .mutateAsync({ patchId: selectedFilePatch.patchId, type: "update", content: fileData || "", }) .then(() => { toast.success("Deletion unmarked"); utils.patch.byEntityId.invalidate({ id, type }); }) .catch(() => { toast.error("Failed to unmark deletion"); }); }; const hasChanges = fileData !== undefined && fileContent !== fileData; const renderTree = useCallback( (entries: DirectoryEntry[], depth = 0) => { return entries .sort((a, b) => { // Directories first, then alphabetically if (a.type !== b.type) { return a.type === "directory" ? -1 : 1; } return a.name.localeCompare(b.name); }) .map((entry) => { const isExpanded = expandedFolders.has(entry.path); const isSelected = selectedFile === entry.path; if (entry.type === "directory") { return (
handleCreateFile(entry.path, filename, content) } onOpenChange={(open) => setCreateFolderPath(open ? entry.path : null) } />
{isExpanded && entry.children && (
{renderTree(entry.children, depth + 1)}
)}
); } const isMarkedForDeletion = patches?.some( (p) => p.filePath === entry.path && p.type === "delete", ); return ( ); }); }, [expandedFolders, selectedFile, patches, handleCreateFile], ); return (
Edit File {selectedFile ? `Editing: ${selectedFile}` : "Select a file from the tree to edit"}
{selectedFile && (
{selectedFilePatch ? ( ) : ( <> )}
)}
handleCreateFile("", filename, content) } onOpenChange={(open) => setCreateFolderPath(open ? "" : null) } /> New file in root
{isDirLoading ? (
) : directories ? ( renderTree(directories) ) : (
No files found
)}
{isFileLoading ? (
) : selectedFile ? ( setFileContent(value || "")} className="h-full w-full" wrapperClassName="h-full" lineWrapping /> ) : (
Select a file to edit
)}
); };