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"} + + + + + Filename + setFilename(e.target.value)} + /> + + + Content + + setContent(v ?? "")} + className="h-full" + wrapperClassName="h-[200px]" + lineWrapping + /> + + + + + + + Cancel + + + + + Create + + + + + + + ); +}; 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 ( - toggleFolder(entry.path)} - className={ - "w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors" - } - style={{ paddingLeft: `${depth * 12 + 8}px` }} - > - + toggleFolder(entry.path)} + className={ + "flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0" + } + style={{ paddingLeft: `${depth * 12 + 8}px` }} + > + + + {entry.name} + + + 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 ( { onClick={() => handleFileSelect(entry.path)} className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${ isSelected ? "bg-muted" : "" - }`} + } ${isMarkedForDeletion ? "text-destructive" : ""}`} style={{ paddingLeft: `${depth * 12 + 28}px` }} > - + {entry.name} + {isMarkedForDeletion && ( + + )} ); }); }, - [expandedFolders, selectedFile], + [expandedFolders, selectedFile, patches, handleCreateFile], ); return ( @@ -173,18 +266,68 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { {selectedFile && ( - - {isSavingPatch && } - - Save Patch - + + {selectedFilePatch ? ( + + {updatePatch.isPending && ( + + )} + Unmark deletion + + ) : ( + <> + + {isMarkingDeletion && ( + + )} + + Mark for deletion + + + {isSavingPatch && ( + + )} + + Save Patch + + > + )} + )} - + + + + 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") && ( + + )} { @@ -280,17 +294,59 @@ export const patchRouter = createTRPCRouter({ ); if (!existingPatch) { - const newPatch = await createPatch({ + return await createPatch({ filePath: input.filePath, content: input.content, + type: input.patchType, applicationId: input.type === "application" ? input.id : undefined, composeId: input.type === "compose" ? input.id : undefined, }); - } else { - return await updatePatch(existingPatch.patchId, { - content: input.content, - }); } + + return await updatePatch(existingPatch.patchId, { + content: input.content, + type: input.patchType, + }); + }), + + markFileForDeletion: protectedProcedure + .input( + z.object({ + id: z.string(), + type: z.enum(["application", "compose"]), + filePath: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.type === "application") { + const app = await findApplicationById(input.id); + if ( + app.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + } else if (input.type === "compose") { + const compose = await findComposeById(input.id); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + } + + return await markPatchForDeletion( + input.filePath, + input.id, + input.type, + ); }), // Cleanup diff --git a/packages/server/src/db/schema/patch.ts b/packages/server/src/db/schema/patch.ts index 5e8dc1056..c4fc1abd2 100644 --- a/packages/server/src/db/schema/patch.ts +++ b/packages/server/src/db/schema/patch.ts @@ -56,6 +56,7 @@ export const patchRelations = relations(patch, ({ one }) => ({ const createSchema = createInsertSchema(patch, { filePath: z.string().min(1), content: z.string(), + type: z.enum(["create", "update", "delete"]).optional(), enabled: z.boolean().optional(), applicationId: z.string().optional(), composeId: z.string().optional(), @@ -64,6 +65,7 @@ const createSchema = createInsertSchema(patch, { export const apiCreatePatch = createSchema.pick({ filePath: true, content: true, + type: true, enabled: true, applicationId: true, composeId: true, diff --git a/packages/server/src/services/patch.ts b/packages/server/src/services/patch.ts index fd6b29a2d..1158655e1 100644 --- a/packages/server/src/services/patch.ts +++ b/packages/server/src/services/patch.ts @@ -106,6 +106,26 @@ export const deletePatch = async (patchId: string) => { return result[0]; }; +export const markPatchForDeletion = async ( + filePath: string, + entityId: string, + entityType: "application" | "compose", +) => { + const existing = await findPatchByFilePath(filePath, entityId, entityType); + + if (existing) { + return await updatePatch(existing.patchId, { type: "delete", content: "" }); + } + + return await createPatch({ + filePath, + content: "", + type: "delete", + applicationId: entityType === "application" ? entityId : undefined, + composeId: entityType === "compose" ? entityId : undefined, + }); +}; + interface ApplyPatchesOptions { id: string; type: "application" | "compose"; @@ -133,12 +153,20 @@ export const generateApplyPatchesCommand = async ({ for (const p of patches) { const filePath = join(codePath, p.filePath); - command += ` + + if (p.type === "delete") { + command += ` + rm -f "${filePath}"; + `; + } else { + // create and update: write file + command += ` file="${filePath}" dir="$(dirname "$file")" mkdir -p "$dir" echo "${encodeBase64(p.content)}" | base64 -d > "$file" `; + } } return command;