diff --git a/backend/server/dist/index.js b/backend/server/dist/index.js index 43ebdcc..cec8a3d 100644 --- a/backend/server/dist/index.js +++ b/backend/server/dist/index.js @@ -129,6 +129,19 @@ io.on("connection", (socket) => __awaiter(void 0, void 0, void 0, function* () { }); yield (0, utils_1.renameFile)(fileId, newFileId, file.data); })); + socket.on("deleteFile", (fileId, callback) => __awaiter(void 0, void 0, void 0, function* () { + const file = sandboxFiles.fileData.find((f) => f.id === fileId); + if (!file) + return; + fs_1.default.unlink(path_1.default.join(dirName, fileId), function (err) { + if (err) + throw err; + }); + sandboxFiles.fileData = sandboxFiles.fileData.filter((f) => f.id !== fileId); + yield (0, utils_1.deleteFile)(fileId); + const newFiles = yield (0, utils_1.getSandboxFiles)(data.id); + callback(newFiles.files); + })); socket.on("createTerminal", ({ id }) => { console.log("creating terminal, id=" + id); const pty = (0, node_pty_1.spawn)(os_1.default.platform() === "win32" ? "cmd.exe" : "bash", [], { diff --git a/backend/server/dist/utils.js b/backend/server/dist/utils.js index a2e19ad..08efd43 100644 --- a/backend/server/dist/utils.js +++ b/backend/server/dist/utils.js @@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.saveFile = exports.renameFile = exports.createFile = exports.getSandboxFiles = void 0; +exports.deleteFile = exports.saveFile = exports.renameFile = exports.createFile = exports.getSandboxFiles = void 0; const getSandboxFiles = (id) => __awaiter(void 0, void 0, void 0, function* () { const sandboxRes = yield fetch(`https://storage.ishaan1013.workers.dev/api?sandboxId=${id}`); const sandboxData = yield sandboxRes.json(); @@ -110,3 +110,14 @@ const saveFile = (fileId, data) => __awaiter(void 0, void 0, void 0, function* ( return res.ok; }); exports.saveFile = saveFile; +const deleteFile = (fileId) => __awaiter(void 0, void 0, void 0, function* () { + const res = yield fetch(`https://storage.ishaan1013.workers.dev/api`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileId }), + }); + return res.ok; +}); +exports.deleteFile = deleteFile; diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index b95f42a..030d18e 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -8,7 +8,13 @@ import { Server } from "socket.io" import { z } from "zod" import { User } from "./types" -import { createFile, getSandboxFiles, renameFile, saveFile } from "./utils" +import { + createFile, + deleteFile, + getSandboxFiles, + renameFile, + saveFile, +} from "./utils" import { IDisposable, IPty, spawn } from "node-pty" dotenv.config() @@ -148,6 +154,21 @@ io.on("connection", async (socket) => { await renameFile(fileId, newFileId, file.data) }) + socket.on("deleteFile", async (fileId: string, callback) => { + const file = sandboxFiles.fileData.find((f) => f.id === fileId) + if (!file) return + + fs.unlink(path.join(dirName, fileId), function (err) { + if (err) throw err + }) + sandboxFiles.fileData = sandboxFiles.fileData.filter((f) => f.id !== fileId) + + await deleteFile(fileId) + + const newFiles = await getSandboxFiles(data.id) + callback(newFiles.files) + }) + socket.on("createTerminal", ({ id }: { id: string }) => { console.log("creating terminal, id=" + id) const pty = spawn(os.platform() === "win32" ? "cmd.exe" : "bash", [], { diff --git a/backend/server/src/utils.ts b/backend/server/src/utils.ts index da8716e..46e2cbb 100644 --- a/backend/server/src/utils.ts +++ b/backend/server/src/utils.ts @@ -123,3 +123,14 @@ export const saveFile = async (fileId: string, data: string) => { }) return res.ok } + +export const deleteFile = async (fileId: string) => { + const res = await fetch(`https://storage.ishaan1013.workers.dev/api`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileId }), + }) + return res.ok +} diff --git a/backend/storage/src/index.ts b/backend/storage/src/index.ts index ccca6da..ac36a8f 100644 --- a/backend/storage/src/index.ts +++ b/backend/storage/src/index.ts @@ -50,6 +50,17 @@ export default { await env.R2.put(fileId, ''); + return success; + } else if (method === 'DELETE') { + const deleteSchema = z.object({ + fileId: z.string(), + }); + + const body = await request.json(); + const { fileId } = deleteSchema.parse(body); + + await env.R2.delete(fileId); + return success; } else return methodNotAllowed; } else if (path === '/api/rename' && method === 'POST') { diff --git a/frontend/components/editor/index.tsx b/frontend/components/editor/index.tsx index d3a8cb2..7849dde 100644 --- a/frontend/components/editor/index.tsx +++ b/frontend/components/editor/index.tsx @@ -27,9 +27,6 @@ import { processFileType, validateName } from "@/lib/utils" import { toast } from "sonner" import EditorTerminal from "./terminal" -import { Terminal } from "@xterm/xterm" -import { FitAddon } from "@xterm/addon-fit" - export default function CodeEditor({ userId, sandboxId, @@ -135,6 +132,9 @@ export default function CodeEditor({ const closeTab = (tab: TFile) => { const numTabs = tabs.length const index = tabs.findIndex((t) => t.id === tab.id) + + if (index === -1) return + const nextId = activeId === tab.id ? numTabs === 1 @@ -156,9 +156,12 @@ export default function CodeEditor({ oldName: string, type: "file" | "folder" ) => { - if (!validateName(newName, oldName, type)) return false + if (!validateName(newName, oldName, type)) { + toast.error("Invalid file name.") + console.log("invalid name") + return false + } - // Action socket.emit("renameFile", id, newName) setTabs((prev) => prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab)) @@ -167,12 +170,27 @@ export default function CodeEditor({ return true } + const handleDeleteFile = (file: TFile) => { + socket.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => { + setFiles(response) + }) + closeTab(file) + } + + const handleDeleteFolder = (folder: TFolder) => { + // socket.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { + // setFiles(response) + // }) + } + return ( <> { if (type === "file") { diff --git a/frontend/components/editor/sidebar/file.tsx b/frontend/components/editor/sidebar/file.tsx index 79058b4..f3208bb 100644 --- a/frontend/components/editor/sidebar/file.tsx +++ b/frontend/components/editor/sidebar/file.tsx @@ -4,11 +4,19 @@ import Image from "next/image" import { getIconForFile } from "vscode-icons-js" import { TFile, TTab } from "./types" import { useEffect, useRef, useState } from "react" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { Loader2, Pencil, Trash2 } from "lucide-react" export default function SidebarFile({ data, selectFile, handleRename, + handleDeleteFile, }: { data: TFile selectFile: (file: TTab) => void @@ -18,16 +26,21 @@ export default function SidebarFile({ oldName: string, type: "file" | "folder" ) => boolean + handleDeleteFile: (file: TFile) => void }) { const [imgSrc, setImgSrc] = useState(`/icons/${getIconForFile(data.name)}`) const [editing, setEditing] = useState(false) const inputRef = useRef(null) + const [pendingDelete, setPendingDelete] = useState(false) useEffect(() => { if (editing) { - inputRef.current?.focus() + setTimeout(() => inputRef.current?.focus(), 0) } - }, [editing]) + if (!inputRef.current) { + console.log("no input ref") + } + }, [editing, inputRef.current]) const renameFile = () => { const renamed = handleRename( @@ -43,37 +56,71 @@ export default function SidebarFile({ } return ( - + {pendingDelete ? ( + <> + +
Deleting...
+ + ) : ( +
{ + e.preventDefault() + renameFile() + }} + > + renameFile()} + /> +
+ )} + + + { + console.log("rename") + setEditing(true) + }} + > + + Rename + + { + console.log("delete") + setPendingDelete(true) + handleDeleteFile(data) + }} + > + + Delete + + + ) } diff --git a/frontend/components/editor/sidebar/folder.tsx b/frontend/components/editor/sidebar/folder.tsx index 91adf39..1bb9c1c 100644 --- a/frontend/components/editor/sidebar/folder.tsx +++ b/frontend/components/editor/sidebar/folder.tsx @@ -10,6 +10,8 @@ export default function SidebarFolder({ data, selectFile, handleRename, + handleDeleteFile, + handleDeleteFolder, }: { data: TFolder selectFile: (file: TTab) => void @@ -19,6 +21,8 @@ export default function SidebarFolder({ oldName: string, type: "file" | "folder" ) => boolean + handleDeleteFile: (file: TFile) => void + handleDeleteFolder: (folder: TFolder) => void }) { const [isOpen, setIsOpen] = useState(false) const folder = isOpen @@ -82,6 +86,7 @@ export default function SidebarFolder({ data={child} selectFile={selectFile} handleRename={handleRename} + handleDeleteFile={handleDeleteFile} /> ) : ( ) )} diff --git a/frontend/components/editor/sidebar/index.tsx b/frontend/components/editor/sidebar/index.tsx index 4897082..84049c4 100644 --- a/frontend/components/editor/sidebar/index.tsx +++ b/frontend/components/editor/sidebar/index.tsx @@ -12,6 +12,8 @@ export default function Sidebar({ files, selectFile, handleRename, + handleDeleteFile, + handleDeleteFolder, socket, addNew, }: { @@ -23,6 +25,8 @@ export default function Sidebar({ oldName: string, type: "file" | "folder" ) => boolean + handleDeleteFile: (file: TFile) => void + handleDeleteFolder: (folder: TFolder) => void socket: Socket addNew: (name: string, type: "file" | "folder") => void }) { @@ -66,6 +70,7 @@ export default function Sidebar({ data={child} selectFile={selectFile} handleRename={handleRename} + handleDeleteFile={handleDeleteFile} /> ) : ( ) )} diff --git a/frontend/components/ui/context-menu.tsx b/frontend/components/ui/context-menu.tsx new file mode 100644 index 0000000..654810a --- /dev/null +++ b/frontend/components/ui/context-menu.tsx @@ -0,0 +1,204 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 6a4244b..fe38567 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,5 +1,5 @@ import { type ClassValue, clsx } from "clsx" -import { toast } from "sonner" +// import { toast } from "sonner" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { @@ -21,18 +21,15 @@ export function validateName( oldName: string, type: "file" | "folder" ) { - if (newName === oldName || newName.length === 0) { - return false - } - if ( + newName === oldName || + newName.length === 0 || newName.includes("/") || newName.includes("\\") || newName.includes(" ") || (type === "file" && !newName.includes(".")) || (type === "folder" && newName.includes(".")) ) { - toast.error("Invalid file name.") return false } return true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 115e167..5403b1f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^3.3.4", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", @@ -707,6 +708,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz", + "integrity": "sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8a39e20..6f50c1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@hookform/resolvers": "^3.3.4", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0",