From 8aba7b08cf41200ac120cdc30608a0ff76a20130 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 17 Feb 2026 02:28:20 -0600 Subject: [PATCH] feat(patch): implement CreateFileDialog and integrate file creation in PatchEditor - Added CreateFileDialog component for creating new files within the dashboard. - Integrated file creation functionality into the PatchEditor, allowing users to create files directly from the directory structure. - Enhanced user experience with form validation and success/error notifications during file creation. - Updated ShowPatches to display file types with badges for better clarity on patch operations. --- .../patches/create-file-dialog.tsx | 107 ++++++++++ .../application/patches/patch-editor.tsx | 197 +++++++++++++++--- .../application/patches/show-patches.tsx | 31 ++- apps/dokploy/server/api/routers/patch.ts | 68 +++++- packages/server/src/db/schema/patch.ts | 2 + packages/server/src/services/patch.ts | 30 ++- 6 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 apps/dokploy/components/dashboard/application/patches/create-file-dialog.tsx diff --git a/apps/dokploy/components/dashboard/application/patches/create-file-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/create-file-dialog.tsx new file mode 100644 index 000000000..5f6f88e36 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/create-file-dialog.tsx @@ -0,0 +1,107 @@ +import { FilePlus } from "lucide-react"; +import { useState } from "react"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface Props { + folderPath: string; + onCreate: (filename: string, content: string) => void; + onOpenChange: (open: boolean) => void; + alwaysVisible?: boolean; +} + +export const CreateFileDialog = ({ + folderPath, + onCreate, + onOpenChange, + alwaysVisible = false, +}: Props) => { + const [filename, setFilename] = useState(""); + const [content, setContent] = useState(""); + + const handleCreate = () => { + if (!filename.trim()) return; + onCreate(filename.trim(), content); + setFilename(""); + setContent(""); + onOpenChange(false); + }; + + return ( + + + + + +
{ + e.preventDefault(); + handleCreate(); + }} + > + + Create file + + {folderPath ? `New file in ${folderPath}/` : "New file in root"} + + +
+
+ + setFilename(e.target.value)} + /> +
+
+ +
+ setContent(v ?? "")} + className="h-full" + wrapperClassName="h-[200px]" + lineWrapping + /> +
+
+
+ + + + + + + + +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx index 9f0608260..ba38e29a6 100644 --- a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -5,8 +5,9 @@ import { Folder, Loader2, Save, + Trash2, } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; @@ -19,6 +20,7 @@ import { } 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; @@ -37,20 +39,31 @@ type DirectoryEntry = { export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(""); - const [originalContent, setOriginalContent] = useState(""); + const [createFolderPath, setCreateFolderPath] = useState(null); const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); + const utils = api.useUtils(); const { data: directories, isLoading: 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, isLoading: isSavingPatch } = api.patch.saveFileAsPatch.useMutation(); + const { mutateAsync: markForDeletion, isLoading: isMarkingDeletion } = + api.patch.markFileForDeletion.useMutation(); + + const updatePatch = api.patch.update.useMutation(); + const { data: fileData, isFetching: isFileLoading } = api.patch.readRepoFile.useQuery( { @@ -63,6 +76,12 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { }, ); + useEffect(() => { + if (fileData !== undefined) { + setFileContent(fileData); + } + }, [fileData]); + const handleFileSelect = (filePath: string) => { setSelectedFile(filePath); }; @@ -86,16 +105,72 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { 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 hasChanges = fileContent !== originalContent; + 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) => { @@ -114,22 +189,33 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { if (entry.type === "directory") { return (
- + + handleCreateFile(entry.path, filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? entry.path : null) + } /> - - {entry.name} - +
{isExpanded && entry.children && (
{renderTree(entry.children, depth + 1)}
)} @@ -137,6 +223,10 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { ); } + const isMarkedForDeletion = patches?.some( + (p) => p.filePath === entry.path && p.type === "delete", + ); + return ( ); }); }, - [expandedFolders, selectedFile], + [expandedFolders, selectedFile, patches, handleCreateFile], ); return ( @@ -173,18 +266,68 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { {selectedFile && ( - +
+ {selectedFilePatch ? ( + + ) : ( + <> + + + + )} +
)}
-
+
+
+ + handleCreateFile("", filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? "" : null) + } + /> + + New file in root + +
{isDirLoading ? (
diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx index eace7d820..1f417ecdf 100644 --- a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -9,6 +9,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Table, @@ -129,6 +130,7 @@ export const ShowPatches = ({ id, type }: Props) => { File Path + Type Enabled Actions @@ -138,10 +140,24 @@ export const ShowPatches = ({ id, type }: Props) => {
- + {patch.filePath}
+ + + {patch.type} + + {
- + {(patch.type === "update" || + patch.type === "create") && ( + + )}