refactor(patches): unify patch handling for applications and composes

- Updated the PatchEditor and ShowPatches components to accept a unified `id` and `type` prop, replacing the previous separate `applicationId` and `composeId` props.
- Refactored API calls in the patch router to handle both application and compose types, improving code clarity and maintainability.
- Enhanced the UI to provide consistent behavior for creating and managing patches across different types.
This commit is contained in:
Mauricio Siu
2026-02-17 00:24:27 -06:00
parent 752f90c330
commit 88f387dd83
6 changed files with 165 additions and 170 deletions

View File

@@ -19,11 +19,10 @@ import {
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
interface Props {
applicationId?: string;
composeId?: string;
id: string;
type: "application" | "compose";
repoPath: string;
onClose: () => void;
}
@@ -35,12 +34,7 @@ type DirectoryEntry = {
children?: DirectoryEntry[];
};
export const PatchEditor = ({
applicationId,
composeId,
repoPath,
onClose,
}: Props) => {
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
@@ -49,14 +43,12 @@ export const PatchEditor = ({
);
const [isSaving, setIsSaving] = useState(false);
// Fetch directory tree
const { data: directories, isLoading: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ applicationId, composeId, repoPath },
{ id: id, type, repoPath },
{ enabled: !!repoPath },
);
// Save mutation
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
onSuccess: (result) => {
setIsSaving(false);
@@ -77,8 +69,8 @@ export const PatchEditor = ({
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
applicationId,
composeId,
id: id || "",
type,
repoPath,
filePath: selectedFile || "",
},
@@ -114,8 +106,8 @@ export const PatchEditor = ({
if (!selectedFile) return;
setIsSaving(true);
saveAsPatch.mutate({
applicationId,
composeId,
id,
type,
repoPath,
filePath: selectedFile,
content: fileContent,
@@ -142,8 +134,11 @@ export const PatchEditor = ({
return (
<div key={entry.path}>
<button
type="button"
onClick={() => 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`}
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` }}
>
<ChevronRight
@@ -163,6 +158,7 @@ export const PatchEditor = ({
return (
<button
type="button"
key={entry.path}
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 ${

View File

@@ -1,15 +1,6 @@
import {
AlertCircle,
ChevronRight,
File,
Folder,
Loader2,
Power,
Trash2,
} from "lucide-react";
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -28,122 +19,81 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
import { PatchEditor } from "./patch-editor";
interface Props {
applicationId?: string;
composeId?: string;
id: string;
type: "application" | "compose";
}
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
export const ShowPatches = ({ applicationId, composeId }: Props) => {
export const ShowPatches = ({ id, type }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
// Fetch patches
// Fetch patches
const { data: appPatches, isLoading: isAppPatchesLoading } =
api.patch.byApplicationId.useQuery(
{ applicationId: applicationId! },
{ enabled: !!applicationId },
);
const queryMap = {
application: () =>
api.patch.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: !!id },
),
compose: () =>
api.patch.byComposeId.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data: composePatches, isLoading: isComposePatchesLoading } =
api.patch.byComposeId.useQuery(
{ composeId: composeId! },
{ enabled: !!composeId },
);
const { data: patches, isLoading: isPatchesLoading } = queryMap[type]
? queryMap[type]()
: api.patch.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: !!id },
);
const patches = applicationId ? appPatches : composePatches;
const isPatchesLoading = applicationId
? isAppPatchesLoading
: isComposePatchesLoading;
// Mutations
const deletePatch = api.patch.delete.useMutation({
onSuccess: () => {
toast.success("Patch deleted");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to delete patch");
},
});
const togglePatch = api.patch.toggleEnabled.useMutation({
onSuccess: () => {
toast.success("Patch updated");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to update patch");
},
});
const mutationMap = {
application: () => api.patch.delete.useMutation(),
compose: () => api.patch.delete.useMutation(),
};
const ensureRepo = api.patch.ensureRepo.useMutation();
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
const toastId = toast.loading("Syncing repository...");
ensureRepo.mutate(
{ applicationId, composeId },
{
onSuccess: (path) => {
setRepoPath(path);
setIsLoadingRepo(false);
toast.dismiss(toastId);
},
onError: () => {
setIsLoadingRepo(false);
toast.dismiss(toastId);
toast.error("Failed to load repository");
},
},
);
};
const togglePatch = api.patch.toggleEnabled.useMutation();
const handleDeletePatch = (patchId: string) => {
deletePatch.mutate({ patchId });
};
const handleTogglePatch = (patchId: string, enabled: boolean) => {
togglePatch.mutate({ patchId, enabled });
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.patch.delete.useMutation();
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
};
if (repoPath) {
return (
<PatchEditor
applicationId={applicationId}
composeId={composeId}
repoPath={repoPath}
id={id}
type={type}
repoPath={repoPath || ""}
onClose={handleCloseEditor}
/>
);
}
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
await ensureRepo
.mutateAsync({ id, type })
.then((result) => {
setRepoPath(result);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
@@ -154,25 +104,39 @@ export const ShowPatches = ({ applicationId, composeId }: Props) => {
applied after cloning the repository and before building.
</CardDescription>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Patch
</Button>
{patches && patches?.length > 0 && (
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
)}
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : !patches || patches.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>No patches</AlertTitle>
<AlertDescription>
No patches have been created for this application yet. Click
"Create Patch" to add modifications to your code during build.
</AlertDescription>
</Alert>
) : patches?.length === 0 ? (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No patches yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add file patches to modify your repo before each buildconfigs,
env, or code. Create your first patch to get started.
</p>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
</div>
) : (
<Table>
<TableHeader>
@@ -183,7 +147,7 @@ export const ShowPatches = ({ applicationId, composeId }: Props) => {
</TableRow>
</TableHeader>
<TableBody>
{patches.map((patch: Patch) => (
{patches?.map((patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
@@ -194,16 +158,49 @@ export const ShowPatches = ({ applicationId, composeId }: Props) => {
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) =>
handleTogglePatch(patch.patchId, checked)
}
onCheckedChange={(checked) => {
togglePatch
.mutateAsync({
patchId: patch.patchId,
enabled: checked,
})
.then(() => {
toast.success("Patch updated");
utils.patch.byApplicationId.invalidate({
applicationId: id,
});
utils.patch.byComposeId.invalidate({
composeId: id,
});
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
}}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeletePatch(patch.patchId)}
onClick={() => {
mutateAsync({ patchId: patch.patchId })
.then(() => {
toast.success("Patch deleted");
utils.patch.byApplicationId.invalidate({
applicationId: id,
});
utils.patch.byComposeId.invalidate({
composeId: id,
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>

View File

@@ -26,11 +26,11 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
@@ -365,7 +365,7 @@ const Service = (
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches applicationId={applicationId} />
<ShowPatches id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -17,9 +17,9 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -367,7 +367,7 @@ const Service = (
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches composeId={composeId} />
<ShowPatches id={composeId} type="compose" />
</div>
</TabsContent>

View File

@@ -6,10 +6,10 @@ import {
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchByFilePath,
findPatchById,
findPatchesByApplicationId,
findPatchesByComposeId,
findPatchByFilePath,
generatePatch,
readPatchRepoDirectory,
readPatchRepoFile,
@@ -218,13 +218,13 @@ export const patchRouter = createTRPCRouter({
ensureRepo: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
id: z.string(),
type: z.enum(["application", "compose"]),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -253,8 +253,8 @@ export const patchRouter = createTRPCRouter({
});
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -285,21 +285,21 @@ export const patchRouter = createTRPCRouter({
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
message: "Either application or compose must be provided",
});
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
id: z.string(),
type: z.enum(["application", "compose"]),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -312,8 +312,8 @@ export const patchRouter = createTRPCRouter({
return await readPatchRepoDirectory(input.repoPath, app.serverId);
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -328,15 +328,15 @@ export const patchRouter = createTRPCRouter({
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
message: "Either application or compose must be provided",
});
}),
readRepoFile: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
id: z.string(),
type: z.enum(["application", "compose"]),
repoPath: z.string(),
filePath: z.string(),
}),
@@ -345,8 +345,8 @@ export const patchRouter = createTRPCRouter({
let serverId: string | null = null;
let patchContent: string | undefined;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -361,14 +361,14 @@ export const patchRouter = createTRPCRouter({
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.id,
undefined,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -384,7 +384,7 @@ export const patchRouter = createTRPCRouter({
const existingPatch = await findPatchByFilePath(
input.filePath,
undefined,
input.composeId,
input.id,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
@@ -407,8 +407,8 @@ export const patchRouter = createTRPCRouter({
saveFileAsPatch: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
id: z.string(),
type: z.enum(["application", "compose"]),
repoPath: z.string(),
filePath: z.string(),
content: z.string(),
@@ -417,8 +417,8 @@ export const patchRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -429,8 +429,8 @@ export const patchRouter = createTRPCRouter({
});
}
serverId = app.serverId;
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
@@ -444,7 +444,7 @@ export const patchRouter = createTRPCRouter({
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
message: "Either application or compose must be provided",
});
}
@@ -460,8 +460,8 @@ export const patchRouter = createTRPCRouter({
// No changes - remove existing patch if any
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
input.id,
input.id,
);
if (existingPatch) {
await deletePatch(existingPatch.patchId);
@@ -472,8 +472,8 @@ export const patchRouter = createTRPCRouter({
// Check if patch exists
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
input.id,
input.id,
);
if (existingPatch) {
@@ -487,8 +487,8 @@ export const patchRouter = createTRPCRouter({
filePath: input.filePath,
content: patchContent,
enabled: true,
applicationId: input.applicationId,
composeId: input.composeId,
applicationId: input.id,
composeId: input.id,
});
return { deleted: false, patchId: newPatch.patchId };

View File

@@ -132,6 +132,8 @@ export const readPatchRepoDirectory = async (
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
console.log("command", command);
let stdout: string;
try {
if (serverId) {