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.
This commit is contained in:
Mauricio Siu
2026-02-17 02:28:20 -06:00
parent 9eeac50642
commit 8aba7b08cf
6 changed files with 395 additions and 40 deletions

View File

@@ -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 (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
title="Create file"
>
<FilePlus className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<DialogHeader>
<DialogTitle>Create file</DialogTitle>
<DialogDescription>
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<Input
id="filename"
placeholder="e.g. .env.example"
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Content</Label>
<div className="h-[200px] rounded-md border">
<CodeEditor
value={content}
onChange={(v) => setContent(v ?? "")}
className="h-full"
wrapperClassName="h-[200px]"
lineWrapping
/>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" disabled={!filename.trim()}>
Create
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -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<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
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 (
<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"
}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
<div className="group flex items-center">
<button
type="button"
onClick={() => 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` }}
>
<ChevronRight
className={`h-4 w-4 shrink-0 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
<CreateFileDialog
folderPath={entry.path}
onCreate={(filename, content) =>
handleCreateFile(entry.path, filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? entry.path : null)
}
/>
<Folder className="h-4 w-4 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
</div>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
@@ -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 (
<button
type="button"
@@ -144,16 +234,19 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
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` }}
>
<File className="h-4 w-4 text-muted-foreground" />
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
{isMarkedForDeletion && (
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
)}
</button>
);
});
},
[expandedFolders, selectedFile],
[expandedFolders, selectedFile, patches, handleCreateFile],
);
return (
@@ -173,18 +266,68 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
</div>
</div>
{selectedFile && (
<Button onClick={handleSave} disabled={isSavingPatch || !hasChanges}>
{isSavingPatch && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
<div className="flex items-center gap-2">
{selectedFilePatch ? (
<Button
variant="outline"
size="sm"
onClick={handleUnmarkDeletion}
disabled={updatePatch.isPending}
>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Unmark deletion
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleMarkForDeletion}
disabled={isMarkingDeletion}
>
{isMarkingDeletion && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trash2 className="mr-2 h-4 w-4" />
Mark for deletion
</Button>
<Button
onClick={handleSave}
disabled={isSavingPatch || !hasChanges}
>
{isSavingPatch && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
</>
)}
</div>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2">
<div className="p-2 space-y-1">
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
<CreateFileDialog
folderPath=""
alwaysVisible
onCreate={(filename, content) =>
handleCreateFile("", filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? "" : null)
}
/>
<span className="text-xs text-muted-foreground">
New file in root
</span>
</div>
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />

View File

@@ -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) => {
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
@@ -138,10 +140,24 @@ export const ShowPatches = ({ id, type }: Props) => {
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground" />
<File className="h-4 w-4 text-muted-foreground shrink-0" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Badge
variant={
patch.type === "delete"
? "destructive"
: patch.type === "create"
? "default"
: "secondary"
}
className="font-normal"
>
{patch.type}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
@@ -169,11 +185,14 @@ export const ShowPatches = ({ id, type }: Props) => {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<EditPatchDialog
patchId={patch.patchId}
entityId={id}
type={type}
/>
{(patch.type === "update" ||
patch.type === "create") && (
<EditPatchDialog
patchId={patch.patchId}
entityId={id}
type={type}
/>
)}
<Button
variant="ghost"
size="icon"

View File

@@ -9,6 +9,7 @@ import {
findPatchByFilePath,
findPatchById,
findPatchesByEntityId,
markPatchForDeletion,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
@@ -228,7 +229,19 @@ export const patchRouter = createTRPCRouter({
input.type,
);
if (existingPatch) {
// 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)";
}
}
if (existingPatch?.content) {
return existingPatch.content;
}
return await readPatchRepoFile(input.id, input.type, input.filePath);
@@ -241,6 +254,7 @@ export const patchRouter = createTRPCRouter({
type: z.enum(["application", "compose"]),
filePath: z.string(),
content: z.string(),
patchType: z.enum(["create", "update"]).default("update"),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -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

View File

@@ -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,

View File

@@ -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;