diff --git a/apps/dokploy/__test__/patches/patch-diff.test.ts b/apps/dokploy/__test__/patches/patch-diff.test.ts new file mode 100644 index 0000000000..8074d801aa --- /dev/null +++ b/apps/dokploy/__test__/patches/patch-diff.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { buildDiffHunks } from "@/lib/patch-diff"; + +describe("buildDiffHunks", () => { + it("marks inserted lines", () => { + const hunks = buildDiffHunks("a\nb", "a\nb\nc"); + + expect(hunks).toEqual([ + { + type: "equal", + originalStart: 0, + currentStart: 0, + originalLines: ["a", "b"], + currentLines: ["a", "b"], + }, + { + type: "insert", + originalStart: 2, + currentStart: 2, + currentLines: ["c"], + }, + ]); + }); + + it("marks deleted lines", () => { + const hunks = buildDiffHunks("a\nb\nc", "a\nc"); + + expect(hunks).toEqual([ + { + type: "equal", + originalStart: 0, + currentStart: 0, + originalLines: ["a"], + currentLines: ["a"], + }, + { + type: "delete", + originalStart: 1, + currentStart: 1, + originalLines: ["b"], + }, + { + type: "equal", + originalStart: 2, + currentStart: 1, + originalLines: ["c"], + currentLines: ["c"], + }, + ]); + }); + + it("coalesces replaced blocks", () => { + const hunks = buildDiffHunks("a\nb\nc", "a\nx\ny\nc"); + + expect(hunks).toEqual([ + { + type: "equal", + originalStart: 0, + currentStart: 0, + originalLines: ["a"], + currentLines: ["a"], + }, + { + type: "replace", + originalStart: 1, + currentStart: 1, + originalLines: ["b"], + currentLines: ["x", "y"], + }, + { + type: "equal", + originalStart: 2, + currentStart: 3, + originalLines: ["c"], + currentLines: ["c"], + }, + ]); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx index 8c5a42836e..3bb28e9711 100644 --- a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx +++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx @@ -1,7 +1,6 @@ import { Loader2, Pencil } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -13,7 +12,15 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { api } from "@/utils/api"; +import { PatchDiffEditor, type PatchViewMode } from "./patch-diff-editor"; interface Props { patchId: string; @@ -33,12 +40,26 @@ export const EditPatchDialog = ({ { enabled: !!patchId }, ); const [content, setContent] = useState(""); + const [viewMode, setViewMode] = useState("editor"); + const { data: patchFile, isPending: isPatchFileLoading } = + api.patch.readRepoFile.useQuery( + { + id: entityId, + type, + filePath: patch?.filePath || "", + }, + { enabled: !!patch?.filePath }, + ); useEffect(() => { - if (patch) { - setContent(patch.content); + if (patchFile) { + setContent(patchFile.patchedContent); } - }, [patch]); + }, [patchFile]); + + useEffect(() => { + setViewMode("editor"); + }, [patchId]); const utils = api.useUtils(); const updatePatch = api.patch.update.useMutation(); @@ -65,26 +86,42 @@ export const EditPatchDialog = ({ - Edit Patch +
+ Edit Patch + +
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
- {isPatchLoading ? ( + {isPatchLoading || isPatchFileLoading ? (
- ) : ( + ) : patch && patchFile ? (
- setContent(value ?? "")} - className="h-[400px] w-full" - wrapperClassName="h-[400px]" - lineWrapping + onChange={setContent} + patchType={patch.type} + mode={viewMode} + className="h-[400px]" />
- )} + ) : null} diff --git a/apps/dokploy/components/dashboard/application/patches/patch-diff-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-diff-editor.tsx new file mode 100644 index 0000000000..497218d1fe --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/patch-diff-editor.tsx @@ -0,0 +1,265 @@ +import { SplitSquareVertical } from "lucide-react"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { buildDiffHunks, type DiffHunk } from "@/lib/patch-diff"; +import { cn } from "@/lib/utils"; + +type PatchType = "create" | "update" | "delete"; +export type PatchViewMode = "editor" | "diff"; +type DiffViewMode = "unified" | "split"; + +interface Props { + filePath: string; + originalContent: string; + value: string; + onChange: (value: string) => void; + patchType: PatchType; + mode: PatchViewMode; + readOnly?: boolean; + className?: string; +} + +const inferLanguage = (filePath: string) => { + if (filePath.endsWith(".json")) return "json" as const; + if (filePath.endsWith(".css")) return "css" as const; + if (filePath.endsWith(".properties")) return "properties" as const; + if ( + filePath.endsWith(".sh") || + filePath.endsWith(".bash") || + filePath.endsWith(".zsh") + ) { + return "shell" as const; + } + return "yaml" as const; +}; + +const renderCodeLines = (content: string, className?: string) => { + const lines = content.split("\n"); + return ( +
+ {lines.map((line, index) => ( +
+ + {index + 1} + +
+						{line || " "}
+					
+
+ ))} +
+ ); +}; + +const renderUnifiedDiff = (hunks: DiffHunk[]) => { + let originalLine = 1; + let currentLine = 1; + + return ( +
+ {hunks.flatMap((hunk, hunkIndex) => { + if (hunk.type === "equal") { + return hunk.currentLines.map((line, lineIndex) => { + const row = ( +
+ + {originalLine++} + + + {currentLine++} + +
+									{line || " "}
+								
+
+ ); + return row; + }); + } + + if (hunk.type === "delete") { + return hunk.originalLines.map((line, lineIndex) => ( +
+ + {originalLine++} + + + - + +
+								- {line || " "}
+							
+
+ )); + } + + if (hunk.type === "insert") { + return hunk.currentLines.map((line, lineIndex) => ( +
+ + + + + + {currentLine++} + +
+								+ {line || " "}
+							
+
+ )); + } + + const removedRows = hunk.originalLines.map((line, lineIndex) => ( +
+ + {originalLine++} + + + - + +
+							- {line || " "}
+						
+
+ )); + const addedRows = hunk.currentLines.map((line, lineIndex) => ( +
+ + + + + + {currentLine++} + +
+							+ {line || " "}
+						
+
+ )); + + return [...removedRows, ...addedRows]; + })} +
+ ); +}; + +export const PatchDiffEditor = ({ + filePath, + originalContent, + value, + onChange, + patchType, + mode, + readOnly = false, + className, +}: Props) => { + const diffHunks = buildDiffHunks(originalContent, value); + const language = inferLanguage(filePath); + const diffViewMode: DiffViewMode = + patchType === "create" ? "unified" : "split"; + const contentLabel = + patchType === "create" + ? "New file content" + : patchType === "delete" + ? "Content scheduled for deletion" + : "Patched content"; + + if (mode === "editor") { + if (patchType === "delete") { + return ( +
+ {renderCodeLines(originalContent)} +
+ ); + } + + return ( + onChange(nextValue || "")} + className={cn("h-full w-full", className)} + wrapperClassName="h-full" + language={language} + lineWrapping + disabled={readOnly} + /> + ); + } + + return ( +
+ {patchType === "delete" ? ( +
+ {renderCodeLines(originalContent)} +
+ ) : diffViewMode === "split" ? ( +
+
+
+ + Original +
+
+ {renderCodeLines(originalContent)} +
+
+
+
+ {contentLabel} +
+ onChange(nextValue || "")} + className="h-full w-full" + wrapperClassName="h-full" + language={language} + lineWrapping + disabled={readOnly} + /> +
+
+ ) : ( +
+
+ Unified diff +
+
+ {renderUnifiedDiff(diffHunks)} +
+
+ {contentLabel} +
+
+ onChange(nextValue || "")} + className="h-full w-full" + wrapperClassName="h-[280px]" + language={language} + lineWrapping + disabled={readOnly} + /> +
+
+ )} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx index 4b212b004d..709700243c 100644 --- a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -4,12 +4,12 @@ import { File, Folder, Loader2, + Maximize2, 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, @@ -19,8 +19,16 @@ import { CardTitle, } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { api } from "@/utils/api"; import { CreateFileDialog } from "./create-file-dialog"; +import { PatchDiffEditor, type PatchViewMode } from "./patch-diff-editor"; interface Props { id: string; @@ -43,6 +51,8 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); + const [isFullscreen, setIsFullscreen] = useState(false); + const [viewMode, setViewMode] = useState("editor"); const utils = api.useUtils(); const { data: directories, isPending: isDirLoading } = @@ -78,10 +88,14 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { useEffect(() => { if (fileData !== undefined) { - setFileContent(fileData); + setFileContent(fileData.patchedContent); } }, [fileData]); + useEffect(() => { + setViewMode("editor"); + }, [selectedFile]); + const handleFileSelect = (filePath: string) => { setSelectedFile(filePath); }; @@ -159,7 +173,7 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { .mutateAsync({ patchId: selectedFilePatch.patchId, type: "update", - content: fileData || "", + content: fileData?.originalContent || "", }) .then(() => { toast.success("Deletion unmarked"); @@ -170,7 +184,10 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { }); }; - const hasChanges = fileData !== undefined && fileContent !== fileData; + const hasChanges = + fileData !== undefined && + fileData.patchType !== "delete" && + fileContent !== fileData.patchedContent; const renderTree = useCallback( (entries: DirectoryEntry[], depth = 0) => { @@ -281,6 +298,14 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { ) : ( <> + )} + )} -
-
+
+
@@ -347,14 +392,19 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
- ) : selectedFile ? ( - setFileContent(value || "")} - className="h-full w-full" - wrapperClassName="h-full" - lineWrapping + ) : selectedFile && fileData ? ( + + ) : selectedFile ? ( +
+ Loading file preview... +
) : (
Select a file to edit diff --git a/apps/dokploy/lib/patch-diff.ts b/apps/dokploy/lib/patch-diff.ts new file mode 100644 index 0000000000..a6b992969c --- /dev/null +++ b/apps/dokploy/lib/patch-diff.ts @@ -0,0 +1,280 @@ +export type DiffHunk = + | { + type: "equal"; + originalStart: number; + currentStart: number; + originalLines: string[]; + currentLines: string[]; + } + | { + type: "insert"; + originalStart: number; + currentStart: number; + currentLines: string[]; + } + | { + type: "delete"; + originalStart: number; + currentStart: number; + originalLines: string[]; + } + | { + type: "replace"; + originalStart: number; + currentStart: number; + originalLines: string[]; + currentLines: string[]; + }; + +type DiffOp = { + type: "equal" | "insert" | "delete"; + originalLines: string[]; + currentLines: string[]; +}; + +const MAX_MATRIX_CELLS = 40_000; + +const splitLines = (content: string) => content.split("\n"); + +const trimCommonEdges = (originalLines: string[], currentLines: string[]) => { + let prefix = 0; + while ( + prefix < originalLines.length && + prefix < currentLines.length && + originalLines[prefix] === currentLines[prefix] + ) { + prefix += 1; + } + + let originalSuffix = originalLines.length - 1; + let currentSuffix = currentLines.length - 1; + while ( + originalSuffix >= prefix && + currentSuffix >= prefix && + originalLines[originalSuffix] === currentLines[currentSuffix] + ) { + originalSuffix -= 1; + currentSuffix -= 1; + } + + return { + prefix, + originalMiddle: originalLines.slice(prefix, originalSuffix + 1), + currentMiddle: currentLines.slice(prefix, currentSuffix + 1), + suffixOriginalStart: originalSuffix + 1, + suffixCurrentStart: currentSuffix + 1, + }; +}; + +const pushOp = ( + ops: DiffOp[], + type: DiffOp["type"], + line: string, + target: "originalLines" | "currentLines", +) => { + const previous = ops.at(-1); + if (previous?.type === type) { + previous[target].push(line); + return; + } + ops.push({ + type, + originalLines: target === "originalLines" ? [line] : [], + currentLines: target === "currentLines" ? [line] : [], + }); +}; + +const diffMiddle = (originalLines: string[], currentLines: string[]) => { + if (originalLines.length === 0 && currentLines.length === 0) { + return [] as DiffOp[]; + } + + if (originalLines.length * currentLines.length > MAX_MATRIX_CELLS) { + const ops: DiffOp[] = []; + if (originalLines.length > 0) { + ops.push({ + type: "delete", + originalLines, + currentLines: [], + }); + } + if (currentLines.length > 0) { + ops.push({ + type: "insert", + originalLines: [], + currentLines, + }); + } + return ops; + } + + const rows = originalLines.length + 1; + const cols = currentLines.length + 1; + const lcs = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let row = originalLines.length - 1; row >= 0; row -= 1) { + for (let col = currentLines.length - 1; col >= 0; col -= 1) { + const originalLine = originalLines[row]; + const currentLine = currentLines[col]; + if ( + originalLine !== undefined && + currentLine !== undefined && + originalLine === currentLine + ) { + lcs[row]![col] = lcs[row + 1]![col + 1]! + 1; + } else { + lcs[row]![col] = Math.max(lcs[row + 1]![col]!, lcs[row]![col + 1]!); + } + } + } + + const ops: DiffOp[] = []; + let row = 0; + let col = 0; + + while (row < originalLines.length && col < currentLines.length) { + const originalLine = originalLines[row]; + const currentLine = currentLines[col]; + if ( + originalLine !== undefined && + currentLine !== undefined && + originalLine === currentLine + ) { + pushOp(ops, "equal", originalLine, "originalLines"); + const previous = ops.at(-1); + if (previous) { + previous.currentLines.push(currentLine); + } + row += 1; + col += 1; + continue; + } + + if (lcs[row + 1]![col]! >= lcs[row]![col + 1]!) { + if (originalLine !== undefined) { + pushOp(ops, "delete", originalLine, "originalLines"); + } + row += 1; + continue; + } + + if (currentLine !== undefined) { + pushOp(ops, "insert", currentLine, "currentLines"); + } + col += 1; + } + + while (row < originalLines.length) { + const originalLine = originalLines[row]; + if (originalLine !== undefined) { + pushOp(ops, "delete", originalLine, "originalLines"); + } + row += 1; + } + + while (col < currentLines.length) { + const currentLine = currentLines[col]; + if (currentLine !== undefined) { + pushOp(ops, "insert", currentLine, "currentLines"); + } + col += 1; + } + + return ops; +}; + +export const buildDiffHunks = ( + originalContent: string, + currentContent: string, +): DiffHunk[] => { + const originalLines = splitLines(originalContent); + const currentLines = splitLines(currentContent); + const trimmed = trimCommonEdges(originalLines, currentLines); + const middleOps = diffMiddle(trimmed.originalMiddle, trimmed.currentMiddle); + const ops: DiffOp[] = []; + + if (trimmed.prefix > 0) { + ops.push({ + type: "equal", + originalLines: originalLines.slice(0, trimmed.prefix), + currentLines: currentLines.slice(0, trimmed.prefix), + }); + } + + ops.push(...middleOps); + + if (trimmed.suffixOriginalStart < originalLines.length) { + ops.push({ + type: "equal", + originalLines: originalLines.slice(trimmed.suffixOriginalStart), + currentLines: currentLines.slice(trimmed.suffixCurrentStart), + }); + } + + const hunks: DiffHunk[] = []; + let originalLine = 0; + let currentLine = 0; + + for (let index = 0; index < ops.length; index += 1) { + const op = ops[index]; + if (!op) { + continue; + } + if (op.type === "equal") { + hunks.push({ + type: "equal", + originalStart: originalLine, + currentStart: currentLine, + originalLines: op.originalLines, + currentLines: op.currentLines, + }); + originalLine += op.originalLines.length; + currentLine += op.currentLines.length; + continue; + } + + const next = ops[index + 1]; + if ( + (op.type === "delete" && next?.type === "insert") || + (op.type === "insert" && next?.type === "delete") + ) { + const deleteOp = op.type === "delete" ? op : next; + const insertOp = op.type === "insert" ? op : next; + if (!deleteOp || !insertOp) { + continue; + } + hunks.push({ + type: "replace", + originalStart: originalLine, + currentStart: currentLine, + originalLines: deleteOp.originalLines, + currentLines: insertOp.currentLines, + }); + originalLine += deleteOp.originalLines.length; + currentLine += insertOp.currentLines.length; + index += 1; + continue; + } + + if (op.type === "delete") { + hunks.push({ + type: "delete", + originalStart: originalLine, + currentStart: currentLine, + originalLines: op.originalLines, + }); + originalLine += op.originalLines.length; + continue; + } + + hunks.push({ + type: "insert", + originalStart: originalLine, + currentStart: currentLine, + currentLines: op.currentLines, + }); + currentLine += op.currentLines.length; + } + + return hunks; +}; diff --git a/apps/dokploy/server/api/routers/patch.ts b/apps/dokploy/server/api/routers/patch.ts index 333dd1856a..e8827ddc79 100644 --- a/apps/dokploy/server/api/routers/patch.ts +++ b/apps/dokploy/server/api/routers/patch.ts @@ -226,18 +226,39 @@ export const patchRouter = createTRPCRouter({ input.id, input.type, ); - // For delete patches, show current file content from repo (what will be deleted) - if (existingPatch?.type === "delete") { - try { - return await readPatchRepoFile(input.id, input.type, input.filePath); - } catch { - return "(File not found in repo - will be removed if it exists)"; - } + let originalContent = ""; + let fileExistsInRepo = true; + + try { + originalContent = await readPatchRepoFile( + input.id, + input.type, + input.filePath, + ); + } catch { + fileExistsInRepo = false; } - if (existingPatch?.content) { - return existingPatch.content; + + if (existingPatch?.type === "delete") { + return { + filePath: input.filePath, + fileExistsInRepo, + originalContent: fileExistsInRepo + ? originalContent + : "(File not found in repo - will be removed if it exists)", + patchedContent: "", + patchType: "delete" as const, + }; } - return await readPatchRepoFile(input.id, input.type, input.filePath); + + return { + filePath: input.filePath, + fileExistsInRepo, + originalContent, + patchedContent: existingPatch?.content ?? originalContent, + patchType: + existingPatch?.type ?? (fileExistsInRepo ? "update" : "create"), + }; }), saveFileAsPatch: protectedProcedure