From b322d2ab9c7a8cf9c25281e06c01d30c489ef170 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 00:10:34 +0800 Subject: [PATCH 01/11] feat: display file contents on select --- components/workspace/file-explorer.css | 43 ++++++++ components/workspace/file-explorer.tsx | 147 +++++++++++-------------- 2 files changed, 106 insertions(+), 84 deletions(-) create mode 100644 components/workspace/file-explorer.css diff --git a/components/workspace/file-explorer.css b/components/workspace/file-explorer.css new file mode 100644 index 0000000..3dabc7c --- /dev/null +++ b/components/workspace/file-explorer.css @@ -0,0 +1,43 @@ +.file-tree { + user-select: none; + border-radius: 0.4em; +} + +.file-tree .tree, +.file-tree .tree-node, +.file-tree .tree-node-group { + list-style: none; + margin: 0; + padding: 0; +} + +.file-tree .tree-branch-wrapper, +.file-tree .tree-node__leaf { + outline: none; + outline: none; +} + +.file-tree .tree-node { + cursor: pointer; +} + +.file-tree .tree-node:hover { + background: rgba(255, 255, 255, 0.1); +} + +.file-tree .tree .tree-node--focused { + background: rgba(255, 255, 255, 0.2); +} + +.file-tree .tree .tree-node--selected { + background: rgba(48, 107, 176); +} + +.file-tree .tree-node__branch { + display: block; +} + +.file-tree .icon { + vertical-align: middle; + margin-right: 5px; +} \ No newline at end of file diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index c0a1689..96fb74c 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -1,90 +1,26 @@ "use client"; -import { - Tree, - TreeViewElement, - File, - Folder, - CollapseButton, -} from "@/components/extension/tree-view-api"; import { db } from "@/data/db"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import useSWR, { mutate } from "swr"; -import { FileIcon } from "./file-icon"; import { FileExplorerTopMenu } from "./file-explorer-top-menu"; import TreeView, { flattenTree } from "react-accessible-treeview"; +import { FolderOpen, FolderClosed } from "lucide-react"; +import { FileIcon } from "./file-icon"; +import "./file-explorer.css"; +import { useSetSearchParams } from "@/lib/set-search-params"; -type TOCProps = { - toc: TreeViewElement[]; - pathname: string; -}; - -const TOC = ({ toc, pathname }: TOCProps) => { - return ( - - {toc.map((element, _) => ( - - ))} - - - ); -}; - -type TreeItemProps = { - elements: TreeViewElement[]; - pathname: string; -}; - -export const TreeItem = ({ elements, pathname }: TreeItemProps) => { - const searchParams = useSearchParams(); - return ( - - ); -}; +interface TreeViewElement { + name: string; + children?: TreeViewElement[]; +} function convert(data: string[]) { const map = new Map(); - const root: { children: TreeViewElement[] } = { children: [] }; + const root: TreeViewElement = { + name: "", + children: [], + }; for (const name of data) { let path = ""; let parent = root; @@ -94,20 +30,24 @@ function convert(data: string[]) { if (!node) { map.set( path, - (node = { id: path.slice(1), name: label } as TreeViewElement) + (node = { + name: label, + metadata: { path: path.slice(1) }, + } as TreeViewElement) ); (parent.children ??= []).push(node); } parent = node; } } - return root.children; + return flattenTree(root); } const FileExplorer = () => { const pathname = usePathname(); + const setSearchParams = useSetSearchParams(); - const { data: toc } = useSWR(`file-explorer-${pathname}`, async () => { + const { data } = useSWR(`file-explorer-${pathname}`, async () => { const files = await db.files.filter((file) => file.path.startsWith(pathname + "/") ); @@ -119,18 +59,57 @@ const FileExplorer = () => { ); }); - console.log(toc); - - if (!toc) return

Loading...

; + if (!data) return

Loading...

; return ( <> - +
+ { + if (!props.isBranch) { + // file + + const { path } = props.element.metadata || {}; + + if (path) setSearchParams("file", encodeURIComponent(path)); + } + }} + nodeRenderer={({ + element, + isBranch, + isExpanded, + getNodeProps, + level, + }) => ( +
+ + + {isBranch ? ( + + ) : ( + + )} + + {element.name} + +
+ )} + /> +
); }; +const FolderIcon = ({ isOpen }: { isOpen: boolean }) => + isOpen ? ( + + ) : ( + + ); + /** * * @returns Function to refresh the file explorer in the current view From 2b4b2ba0d99c8908df2adc0cd6f19fa2da751274 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 01:42:15 +0800 Subject: [PATCH 02/11] feat: context menu --- components/ui/context-menu.tsx | 200 +++++++++++++++++++++++++ components/workspace/context-menu.tsx | 73 +++++++++ components/workspace/file-explorer.tsx | 51 +++++-- package-lock.json | 29 ++++ package.json | 1 + 5 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 components/ui/context-menu.tsx create mode 100644 components/workspace/context-menu.tsx diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000..93ef37b --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +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/components/workspace/context-menu.tsx b/components/workspace/context-menu.tsx new file mode 100644 index 0000000..72404ae --- /dev/null +++ b/components/workspace/context-menu.tsx @@ -0,0 +1,73 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Pencil, Delete, FilePlus, FolderPlus } from "lucide-react"; +import { Element } from "./file-explorer"; + +export function FileContextMenu({ + children, +}: React.PropsWithChildren<{ element: Element }>) { + return ( + + {children} + { + e.stopPropagation(); + }} + > + + + Rename + F2 + + + + Delete + Del + + + + ); +} + +export function FolderContextMenu({ + children, +}: React.PropsWithChildren<{ element: Element }>) { + return ( + + {children} + { + e.stopPropagation(); + }} + > + + + New File + Ctrl+N + + + + New Folder + Ctrl+Shift+N + + + + Rename + F2 + + + + Delete + Del + + + + ); +} diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 96fb74c..b62829d 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -4,11 +4,13 @@ import { db } from "@/data/db"; import { usePathname } from "next/navigation"; import useSWR, { mutate } from "swr"; import { FileExplorerTopMenu } from "./file-explorer-top-menu"; -import TreeView, { flattenTree } from "react-accessible-treeview"; +import TreeView, { flattenTree, INode } from "react-accessible-treeview"; import { FolderOpen, FolderClosed } from "lucide-react"; import { FileIcon } from "./file-icon"; import "./file-explorer.css"; import { useSetSearchParams } from "@/lib/set-search-params"; +import { FileContextMenu, FolderContextMenu } from "./context-menu"; +import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils"; interface TreeViewElement { name: string; @@ -85,16 +87,11 @@ const FileExplorer = () => { level, }) => (
- - - {isBranch ? ( - - ) : ( - - )} - - {element.name} - +
)} /> @@ -103,6 +100,38 @@ const FileExplorer = () => { ); }; +export type Element = INode; + +const NodeRenderer = ({ + isBranch, + isExpanded, + element, +}: { + isBranch: boolean; + isExpanded: boolean; + element: Element; +}) => { + const { name } = element; + const node = ( + + + {isBranch ? ( + + ) : ( + + )} + + {name} + + ); + + return isBranch ? ( + {node} + ) : ( + {node} + ); +}; + const FolderIcon = ({ isOpen }: { isOpen: boolean }) => isOpen ? ( diff --git a/package-lock.json b/package-lock.json index 03485b9..1d5b606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@octokit/plugin-throttling": "^9.3.1", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", @@ -2008,6 +2009,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.1.tgz", + "integrity": "sha512-wvMKKIeb3eOrkJ96s722vcidZ+2ZNfcYZWBPRHIB1VWrF+fiF851Io6LX0kmK5wTDQFKdulCCKJk2c3SBaQHvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-menu": "2.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", diff --git a/package.json b/package.json index efecc51..57b3cb4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@octokit/plugin-throttling": "^9.3.1", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", From d699b77d79628c2174335714b076e88e9c7933f2 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 04:53:45 +0800 Subject: [PATCH 03/11] feat: rename and delete file --- components/workspace/context-menu.tsx | 68 +++++++++------- components/workspace/delete.tsx | 63 +++++++++++++++ components/workspace/file-explorer.tsx | 48 +++++++++-- components/workspace/rename-form.tsx | 106 +++++++++++++++++++++++++ components/workspace/rename.tsx | 40 ++++++++++ 5 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 components/workspace/delete.tsx create mode 100644 components/workspace/rename-form.tsx create mode 100644 components/workspace/rename.tsx diff --git a/components/workspace/context-menu.tsx b/components/workspace/context-menu.tsx index 72404ae..5bbe262 100644 --- a/components/workspace/context-menu.tsx +++ b/components/workspace/context-menu.tsx @@ -6,11 +6,20 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Pencil, Delete, FilePlus, FolderPlus } from "lucide-react"; -import { Element } from "./file-explorer"; +import React from "react"; -export function FileContextMenu({ - children, -}: React.PropsWithChildren<{ element: Element }>) { +export enum IAction { + RENAME, + DELETE, + NEW_FILE, + NEW_FOLDER, +} + +interface IContextMenuProps extends React.PropsWithChildren { + handleClick: (action: IAction) => void; +} + +export function FileContextMenu({ children, handleClick }: IContextMenuProps) { return ( {children} @@ -20,16 +29,8 @@ export function FileContextMenu({ e.stopPropagation(); }} > - - - Rename - F2 - - - - Delete - Del - + handleClick(IAction.RENAME)} /> + handleClick(IAction.DELETE)} /> ); @@ -37,7 +38,8 @@ export function FileContextMenu({ export function FolderContextMenu({ children, -}: React.PropsWithChildren<{ element: Element }>) { + handleClick, +}: IContextMenuProps) { return ( {children} @@ -47,27 +49,39 @@ export function FolderContextMenu({ e.stopPropagation(); }} > - + handleClick(IAction.NEW_FILE)}> New File Ctrl+N - + handleClick(IAction.NEW_FOLDER)}> New Folder Ctrl+Shift+N - - - Rename - F2 - - - - Delete - Del - + handleClick(IAction.RENAME)} /> + handleClick(IAction.DELETE)} /> ); } + +interface IMenuItemProps { + onClick: () => void; +} + +const DeleteItem = ({ onClick }: IMenuItemProps) => ( + + + Delete + Del + +); + +const RenameItem = ({ onClick }: IMenuItemProps) => ( + + + Rename + F2 + +); \ No newline at end of file diff --git a/components/workspace/delete.tsx b/components/workspace/delete.tsx new file mode 100644 index 0000000..bcaf6eb --- /dev/null +++ b/components/workspace/delete.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "../ui/button"; +import { db } from "@/data/db"; +import { usePathname } from "next/navigation"; +import { useRefreshFileExplorer } from "./file-explorer"; + +export default function Delete({ + type, + path, + isOpen, + setIsOpen, +}: React.PropsWithChildren<{ + type?: "file" | "folder"; + path?: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}>) { + const pathname = usePathname(); + const refreshFileExplorer = useRefreshFileExplorer(); + + if (!path) return null; + + return ( + + + + Delete {type} + + Are you sure you want to delete {path}? This action is not + reversible! + + + + + + + + + ); +} diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index b62829d..7b7e7cb 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -9,8 +9,11 @@ import { FolderOpen, FolderClosed } from "lucide-react"; import { FileIcon } from "./file-icon"; import "./file-explorer.css"; import { useSetSearchParams } from "@/lib/set-search-params"; -import { FileContextMenu, FolderContextMenu } from "./context-menu"; +import { FileContextMenu, FolderContextMenu, IAction } from "./context-menu"; import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils"; +import { useState } from "react"; +import Rename from "./rename"; +import Delete from "./delete"; interface TreeViewElement { name: string; @@ -48,6 +51,10 @@ function convert(data: string[]) { const FileExplorer = () => { const pathname = usePathname(); const setSearchParams = useSetSearchParams(); + const [selectedPath, setSelectedPath] = useState(); + const [showRename, setShowRename] = useState(false); + const [type, setType] = useState<"file" | "folder">(); + const [showDelete, setShowDelete] = useState(false); const { data } = useSWR(`file-explorer-${pathname}`, async () => { const files = await db.files.filter((file) => @@ -71,12 +78,12 @@ const FileExplorer = () => { data={data} aria-label="directory tree" onNodeSelect={(props) => { - if (!props.isBranch) { - // file + const { path } = props.element.metadata || {}; - const { path } = props.element.metadata || {}; + if (typeof path !== "string") return; - if (path) setSearchParams("file", encodeURIComponent(path)); + if (!props.isBranch) { + setSearchParams("file", encodeURIComponent(path)); } }} nodeRenderer={({ @@ -91,11 +98,36 @@ const FileExplorer = () => { isBranch={isBranch} isExpanded={isExpanded} element={element} + handleClick={(action) => { + setType(isBranch ? "folder" : "file"); + setSelectedPath(String(element.metadata!.path)); + + switch (action) { + case IAction.RENAME: + setShowRename(true); + break; + case IAction.DELETE: + setShowDelete(true); + break; + } + }} /> )} /> + + ); }; @@ -106,10 +138,12 @@ const NodeRenderer = ({ isBranch, isExpanded, element, + handleClick, }: { isBranch: boolean; isExpanded: boolean; element: Element; + handleClick: (action: IAction) => void; }) => { const { name } = element; const node = ( @@ -126,9 +160,9 @@ const NodeRenderer = ({ ); return isBranch ? ( - {node} + {node} ) : ( - {node} + {node} ); }; diff --git a/components/workspace/rename-form.tsx b/components/workspace/rename-form.tsx new file mode 100644 index 0000000..ea161e1 --- /dev/null +++ b/components/workspace/rename-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { usePathname } from "next/navigation"; +import { db } from "@/data/db"; +import { Loader2 } from "lucide-react"; +import { useRefreshFileExplorer } from "./file-explorer"; + +const FormSchema = z.object({ + path: z.string(), +}); + +export function RenameForm({ + onSubmit, + type, + path, +}: { + onSubmit?: (path: string) => void; + type?: "file" | "folder"; + path?: string; +}) { + const pathname = usePathname(); + const refreshFileExplorer = useRefreshFileExplorer(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + path, + }, + }); + + async function _onSubmit(data: z.infer) { + form.clearErrors(); + + if (!path) return; + + try { + if (type === "file") { + const currentKey = `${pathname}/${encodeURIComponent(path)}`; + const currentFile = await db.files.get(currentKey); + + const newKey = `${pathname}/${encodeURIComponent(data.path)}`; + await db.files.add({ + path: newKey, + contents: currentFile?.contents || "", + }); + await db.files.delete(currentKey); + } + + await refreshFileExplorer(); + onSubmit?.(data.path); + } catch (err) { + form.setError("path", { message: "Path already exists." }); + } + } + + return ( +
+ + ( + + Path + + + + + This is the new path of your {type}. + + + + )} + /> + + + + ); +} diff --git a/components/workspace/rename.tsx b/components/workspace/rename.tsx new file mode 100644 index 0000000..517887d --- /dev/null +++ b/components/workspace/rename.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +import { RenameForm } from "./rename-form"; + +export default function Rename({ + type, + path, + isOpen, + setIsOpen, +}: React.PropsWithChildren<{ + type?: "file" | "folder"; + path?: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}>) { + return ( + + + + Rename {type} + + setIsOpen(false)} + /> + + + + + ); +} From 33ad2c185edfc2c8e1d36d78c3c465d562c28966 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 06:01:08 +0800 Subject: [PATCH 04/11] feat: folder handling --- components/workspace/delete.tsx | 3 ++ components/workspace/file-explorer.tsx | 69 ++++++++++++++++++++------ components/workspace/rename-form.tsx | 9 ++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/components/workspace/delete.tsx b/components/workspace/delete.tsx index bcaf6eb..20d333e 100644 --- a/components/workspace/delete.tsx +++ b/components/workspace/delete.tsx @@ -47,6 +47,9 @@ export default function Delete({ await db.files.delete( `${pathname}/${encodeURIComponent(path)}` ); + } else { + const all = (await db.files.filter(file => file.path.startsWith(`${pathname}/${encodeURIComponent(path)}`)).toArray()).map(i => i.path); + await db.files.bulkDelete(all); } await refreshFileExplorer(); diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 7b7e7cb..c2e3523 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -82,10 +82,29 @@ const FileExplorer = () => { if (typeof path !== "string") return; - if (!props.isBranch) { + const { children } = props.element; + + setType(children.length > 0 ? "folder" : "file"); + setSelectedPath(path); + + if (children.length === 0) { setSearchParams("file", encodeURIComponent(path)); } }} + // @ts-expect-error + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "F2") { + e.stopPropagation(); + + setShowRename(true); + } + + if (e.code === "Delete") { + e.stopPropagation(); + + setShowDelete(true); + } + }} nodeRenderer={({ element, isBranch, @@ -98,19 +117,6 @@ const FileExplorer = () => { isBranch={isBranch} isExpanded={isExpanded} element={element} - handleClick={(action) => { - setType(isBranch ? "folder" : "file"); - setSelectedPath(String(element.metadata!.path)); - - switch (action) { - case IAction.RENAME: - setShowRename(true); - break; - case IAction.DELETE: - setShowDelete(true); - break; - } - }} /> )} @@ -138,14 +144,33 @@ const NodeRenderer = ({ isBranch, isExpanded, element, - handleClick, }: { isBranch: boolean; isExpanded: boolean; element: Element; - handleClick: (action: IAction) => void; }) => { + const [showRename, setShowRename] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const { name } = element; + + const { path } = element.metadata || {}; + + const type = isBranch ? "folder" : "file"; + + const handleClick = (action: IAction) => { + switch (action) { + case IAction.DELETE: + setShowDelete(true); + break; + case IAction.RENAME: + setShowRename(true); + break; + } + }; + + if (typeof path !== "string") return null; + const node = ( @@ -156,6 +181,18 @@ const NodeRenderer = ({ )} {name} + + ); diff --git a/components/workspace/rename-form.tsx b/components/workspace/rename-form.tsx index ea161e1..745f5bd 100644 --- a/components/workspace/rename-form.tsx +++ b/components/workspace/rename-form.tsx @@ -59,6 +59,15 @@ export function RenameForm({ contents: currentFile?.contents || "", }); await db.files.delete(currentKey); + } else { + const currentKey = `${pathname}/${encodeURIComponent(path)}`; + const currentFiles = await db.files.filter(file => file.path.startsWith(currentKey)).toArray(); + + const newKey = `${pathname}/${encodeURIComponent(data.path)}`; + const newFiles = currentFiles.map(i => ({...i, path: i.path.replace(currentKey, newKey)})); + + await db.files.bulkAdd(newFiles); + await db.files.bulkDelete(currentFiles.map(i => i.path)); } await refreshFileExplorer(); From 8da15e2c97e3107e3a254e32bb90b5f64fe86655 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 06:58:34 +0800 Subject: [PATCH 05/11] refactor: use reducer --- components/workspace/file-explorer.tsx | 143 +++++++++++++++++++------ 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index c2e3523..38a9ef7 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -11,7 +11,7 @@ import "./file-explorer.css"; import { useSetSearchParams } from "@/lib/set-search-params"; import { FileContextMenu, FolderContextMenu, IAction } from "./context-menu"; import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils"; -import { useState } from "react"; +import { useReducer } from "react"; import Rename from "./rename"; import Delete from "./delete"; @@ -48,13 +48,75 @@ function convert(data: string[]) { return flattenTree(root); } +// An enum with all the types of actions to use in our reducer +enum IFileExplorerActionKind { + RENAME, + DELETE, + SELECT, + CLOSE_MODAL, +} + +type FileOrFolder = "file" | "folder"; + +// An interface for our actions +interface IFileExplorerAction { + type: IFileExplorerActionKind; + payload?: { path?: string; type?: FileOrFolder }; +} + +// An interface for our state +interface IFileExplorerState { + showRename: boolean; + showDelete: boolean; + path?: string; + type?: FileOrFolder; +} + +const initialState: IFileExplorerState = { + showRename: false, + showDelete: false, +}; + +// Our reducer function that uses a switch statement to handle our actions +function counterReducer( + state: IFileExplorerState, + action: IFileExplorerAction +) { + const { type, payload } = action; + switch (type) { + case IFileExplorerActionKind.RENAME: + return { + ...state, + ...(payload || {}), + showRename: true, + }; + case IFileExplorerActionKind.DELETE: + return { + ...state, + ...(payload || {}), + showDelete: true, + }; + case IFileExplorerActionKind.SELECT: + return { + ...state, + ...(payload || {}), + }; + case IFileExplorerActionKind.CLOSE_MODAL: + return { + ...state, + showRename: false, + showDelete: false, + }; + default: + return state; + } +} + const FileExplorer = () => { const pathname = usePathname(); const setSearchParams = useSetSearchParams(); - const [selectedPath, setSelectedPath] = useState(); - const [showRename, setShowRename] = useState(false); - const [type, setType] = useState<"file" | "folder">(); - const [showDelete, setShowDelete] = useState(false); + + const [state, dispatch] = useReducer(counterReducer, initialState); const { data } = useSWR(`file-explorer-${pathname}`, async () => { const files = await db.files.filter((file) => @@ -84,8 +146,10 @@ const FileExplorer = () => { const { children } = props.element; - setType(children.length > 0 ? "folder" : "file"); - setSelectedPath(path); + dispatch({ + type: IFileExplorerActionKind.SELECT, + payload: { type: children.length > 0 ? "folder" : "file", path }, + }); if (children.length === 0) { setSearchParams("file", encodeURIComponent(path)); @@ -96,13 +160,13 @@ const FileExplorer = () => { if (e.key === "F2") { e.stopPropagation(); - setShowRename(true); + dispatch({ type: IFileExplorerActionKind.RENAME }); } if (e.code === "Delete") { e.stopPropagation(); - setShowDelete(true); + dispatch({ type: IFileExplorerActionKind.DELETE }); } }} nodeRenderer={({ @@ -117,22 +181,44 @@ const FileExplorer = () => { isBranch={isBranch} isExpanded={isExpanded} element={element} + handleRename={() => + dispatch({ + type: IFileExplorerActionKind.RENAME, + payload: { + type: isBranch ? "folder" : "file", + path: element.metadata!.path as string, + }, + }) + } + handleDelete={() => + dispatch({ + type: IFileExplorerActionKind.DELETE, + payload: { + type: isBranch ? "folder" : "file", + path: element.metadata!.path as string, + }, + }) + } /> )} /> + dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }) + } /> + dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }) + } /> ); @@ -144,14 +230,15 @@ const NodeRenderer = ({ isBranch, isExpanded, element, + handleRename, + handleDelete, }: { isBranch: boolean; isExpanded: boolean; element: Element; + handleRename: () => void; + handleDelete: () => void; }) => { - const [showRename, setShowRename] = useState(false); - const [showDelete, setShowDelete] = useState(false); - const { name } = element; const { path } = element.metadata || {}; @@ -161,10 +248,10 @@ const NodeRenderer = ({ const handleClick = (action: IAction) => { switch (action) { case IAction.DELETE: - setShowDelete(true); + handleDelete(); break; case IAction.RENAME: - setShowRename(true); + handleRename(); break; } }; @@ -181,18 +268,6 @@ const NodeRenderer = ({ )}
{name} - - ); From 5782b8c6cf3681307f0125a4b9e28b0c072a66f4 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 07:11:24 +0800 Subject: [PATCH 06/11] refactor: use id instead --- components/workspace/file-explorer.tsx | 31 ++++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 38a9ef7..1734750 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -37,6 +37,7 @@ function convert(data: string[]) { path, (node = { name: label, + id: path.slice(1), metadata: { path: path.slice(1) }, } as TreeViewElement) ); @@ -70,6 +71,7 @@ interface IFileExplorerState { showDelete: boolean; path?: string; type?: FileOrFolder; + focusedId?: string; } const initialState: IFileExplorerState = { @@ -78,10 +80,7 @@ const initialState: IFileExplorerState = { }; // Our reducer function that uses a switch statement to handle our actions -function counterReducer( - state: IFileExplorerState, - action: IFileExplorerAction -) { +function reducer(state: IFileExplorerState, action: IFileExplorerAction) { const { type, payload } = action; switch (type) { case IFileExplorerActionKind.RENAME: @@ -116,7 +115,7 @@ const FileExplorer = () => { const pathname = usePathname(); const setSearchParams = useSetSearchParams(); - const [state, dispatch] = useReducer(counterReducer, initialState); + const [state, dispatch] = useReducer(reducer, initialState); const { data } = useSWR(`file-explorer-${pathname}`, async () => { const files = await db.files.filter((file) => @@ -138,21 +137,25 @@ const FileExplorer = () => {
{ - const { path } = props.element.metadata || {}; + const { id } = props.element; - if (typeof path !== "string") return; + if (typeof id !== "string") return; const { children } = props.element; dispatch({ type: IFileExplorerActionKind.SELECT, - payload: { type: children.length > 0 ? "folder" : "file", path }, + payload: { + type: children.length > 0 ? "folder" : "file", + path: id, + }, }); if (children.length === 0) { - setSearchParams("file", encodeURIComponent(path)); + setSearchParams("file", encodeURIComponent(id)); } }} // @ts-expect-error @@ -186,7 +189,7 @@ const FileExplorer = () => { type: IFileExplorerActionKind.RENAME, payload: { type: isBranch ? "folder" : "file", - path: element.metadata!.path as string, + path: element.id as string, }, }) } @@ -195,7 +198,7 @@ const FileExplorer = () => { type: IFileExplorerActionKind.DELETE, payload: { type: isBranch ? "folder" : "file", - path: element.metadata!.path as string, + path: element.id as string, }, }) } @@ -241,10 +244,6 @@ const NodeRenderer = ({ }) => { const { name } = element; - const { path } = element.metadata || {}; - - const type = isBranch ? "folder" : "file"; - const handleClick = (action: IAction) => { switch (action) { case IAction.DELETE: @@ -256,8 +255,6 @@ const NodeRenderer = ({ } }; - if (typeof path !== "string") return null; - const node = ( From 41bcec2dbcc6ea42b561e136d205c44b64008ecc Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 08:25:57 +0800 Subject: [PATCH 07/11] refactor: simplify --- components/workspace/file-explorer.tsx | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 1734750..0ad99f8 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -255,23 +255,24 @@ const NodeRenderer = ({ } }; - const node = ( - - - {isBranch ? ( + return isBranch ? ( + + + - ) : ( - - )} + + {name} - {name} - - ); - - return isBranch ? ( - {node} + ) : ( - {node} + + + + + + {name} + + ); }; From db5bf4eff6ca16d8a69c0dd51819eda533feb944 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 08:59:43 +0800 Subject: [PATCH 08/11] refactor: remove unused --- components/workspace/file-explorer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 0ad99f8..28dcb00 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -38,7 +38,6 @@ function convert(data: string[]) { (node = { name: label, id: path.slice(1), - metadata: { path: path.slice(1) }, } as TreeViewElement) ); (parent.children ??= []).push(node); From 785d8917081057d9a720da5d0d764176f73b55af Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 11:35:06 +0800 Subject: [PATCH 09/11] feat: add and delete --- components/workspace/delete.tsx | 10 ++- components/workspace/file-explorer.tsx | 92 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/components/workspace/delete.tsx b/components/workspace/delete.tsx index 20d333e..88ab4b4 100644 --- a/components/workspace/delete.tsx +++ b/components/workspace/delete.tsx @@ -48,7 +48,15 @@ export default function Delete({ `${pathname}/${encodeURIComponent(path)}` ); } else { - const all = (await db.files.filter(file => file.path.startsWith(`${pathname}/${encodeURIComponent(path)}`)).toArray()).map(i => i.path); + const all = ( + await db.files + .filter((file) => + file.path.startsWith( + `${pathname}/${encodeURIComponent(path + "/")}` + ) + ) + .toArray() + ).map((i) => i.path); await db.files.bulkDelete(all); } diff --git a/components/workspace/file-explorer.tsx b/components/workspace/file-explorer.tsx index 28dcb00..5cf7344 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/workspace/file-explorer.tsx @@ -54,6 +54,7 @@ enum IFileExplorerActionKind { DELETE, SELECT, CLOSE_MODAL, + ADD, } type FileOrFolder = "file" | "folder"; @@ -71,11 +72,14 @@ interface IFileExplorerState { path?: string; type?: FileOrFolder; focusedId?: string; + showAdd: boolean; + addType?: FileOrFolder; } const initialState: IFileExplorerState = { showRename: false, showDelete: false, + showAdd: false, }; // Our reducer function that uses a switch statement to handle our actions @@ -104,6 +108,13 @@ function reducer(state: IFileExplorerState, action: IFileExplorerAction) { ...state, showRename: false, showDelete: false, + showAdd: false, + }; + case IFileExplorerActionKind.ADD: + return { + ...state, + addType: payload?.type, + showAdd: true, }; default: return state; @@ -113,6 +124,7 @@ function reducer(state: IFileExplorerState, action: IFileExplorerAction) { const FileExplorer = () => { const pathname = usePathname(); const setSearchParams = useSetSearchParams(); + const refreshFileExplorer = useRefreshFileExplorer(); const [state, dispatch] = useReducer(reducer, initialState); @@ -170,6 +182,26 @@ const FileExplorer = () => { dispatch({ type: IFileExplorerActionKind.DELETE }); } + + if (state.type === "folder") { + if (e.ctrlKey && e.key.toUpperCase() === "N") { + if (e.shiftKey) { + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "folder", + }, + }); + } else { + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "file", + }, + }); + } + } + } }} nodeRenderer={({ element, @@ -201,7 +233,55 @@ const FileExplorer = () => { }, }) } + handleNewFile={() => + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "file", + path: element.id as string, + }, + }) + } + handleNewFolder={() => + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "folder", + path: element.id as string, + }, + }) + } /> + {state.showAdd && state.path === element.id ? ( + { + setTimeout(() => { + ref?.focus(); + }, 200); + }} + className="ml-8" + onBlur={() => + dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }) + } + onKeyDown={async (e) => { + if (e.key === "Escape") + dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }); + + if (e.key === "Enter") { + await db.files.add({ + path: `${pathname}/${encodeURIComponent( + `${state.path}/${e.currentTarget.value}${ + state.addType === "folder" ? "/.gitkeep" : "" + }` + )}`, + contents: "", + }); + await refreshFileExplorer(); + dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }); + } + }} + /> + ) : null}
)} /> @@ -234,12 +314,16 @@ const NodeRenderer = ({ element, handleRename, handleDelete, + handleNewFile, + handleNewFolder, }: { isBranch: boolean; isExpanded: boolean; element: Element; handleRename: () => void; handleDelete: () => void; + handleNewFile: () => void; + handleNewFolder: () => void; }) => { const { name } = element; @@ -251,9 +335,17 @@ const NodeRenderer = ({ case IAction.RENAME: handleRename(); break; + case IAction.NEW_FILE: + handleNewFile(); + break; + case IAction.NEW_FOLDER: + handleNewFolder(); + break; } }; + if (name.startsWith(".")) return null; + return isBranch ? ( From aaa53b5ee078fbe9f71757f7c029fbe2d54bc4d0 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 12:48:46 +0800 Subject: [PATCH 10/11] refactor: file explorer folder --- app/nodejs-cli/page.tsx | 2 +- app/tutorials/[id]/layout.tsx | 2 +- app/workspace/[id]/page.tsx | 2 +- .../context-menu.tsx | 0 .../{workspace => file-explorer}/delete.tsx | 2 +- .../file-explorer/file-explorer-context.ts | 14 ++ .../file-explorer-node-renderer.tsx | 87 ++++++++ .../file-explorer/file-explorer-reducer.ts | 80 +++++++ .../file-explorer-top-menu.tsx | 0 .../file-explorer.css | 0 .../file-icon.tsx | 0 components/file-explorer/folder-icon.tsx | 8 + .../index.tsx} | 196 +----------------- .../new-file-form.tsx | 2 +- .../{workspace => file-explorer}/new-file.tsx | 0 .../rename-form.tsx | 13 +- .../{workspace => file-explorer}/rename.tsx | 0 17 files changed, 213 insertions(+), 195 deletions(-) rename components/{workspace => file-explorer}/context-menu.tsx (100%) rename components/{workspace => file-explorer}/delete.tsx (97%) create mode 100644 components/file-explorer/file-explorer-context.ts create mode 100644 components/file-explorer/file-explorer-node-renderer.tsx create mode 100644 components/file-explorer/file-explorer-reducer.ts rename components/{workspace => file-explorer}/file-explorer-top-menu.tsx (100%) rename components/{workspace => file-explorer}/file-explorer.css (100%) rename components/{workspace => file-explorer}/file-icon.tsx (100%) create mode 100644 components/file-explorer/folder-icon.tsx rename components/{workspace/file-explorer.tsx => file-explorer/index.tsx} (53%) rename components/{workspace => file-explorer}/new-file-form.tsx (97%) rename components/{workspace => file-explorer}/new-file.tsx (100%) rename components/{workspace => file-explorer}/rename-form.tsx (88%) rename components/{workspace => file-explorer}/rename.tsx (100%) diff --git a/app/nodejs-cli/page.tsx b/app/nodejs-cli/page.tsx index ec0e435..887f069 100644 --- a/app/nodejs-cli/page.tsx +++ b/app/nodejs-cli/page.tsx @@ -7,7 +7,7 @@ import { } from "@/components/ui/resizable"; import NodeTerminal from "@/components/webcontainer/node-terminal"; import Editor from "@/components/workspace/editor"; -import FileExplorer from "@/components/workspace/file-explorer"; +import FileExplorer from "@/components/file-explorer"; export default function Page() { return ( diff --git a/app/tutorials/[id]/layout.tsx b/app/tutorials/[id]/layout.tsx index d9aa35f..feab348 100644 --- a/app/tutorials/[id]/layout.tsx +++ b/app/tutorials/[id]/layout.tsx @@ -9,7 +9,7 @@ import { } from "@/components/ui/resizable"; import Cli from "@/components/workspace/cli"; import Editor from "@/components/workspace/editor"; -import FileExplorer from "@/components/workspace/file-explorer"; +import FileExplorer from "@/components/file-explorer"; import { PropsWithChildren } from "react"; export default function Layout({ children }: PropsWithChildren) { diff --git a/app/workspace/[id]/page.tsx b/app/workspace/[id]/page.tsx index 093a0cc..6732eac 100644 --- a/app/workspace/[id]/page.tsx +++ b/app/workspace/[id]/page.tsx @@ -9,7 +9,7 @@ import { } from "@/components/ui/resizable"; import Cli from "@/components/workspace/cli"; import Editor from "@/components/workspace/editor"; -import FileExplorer from "@/components/workspace/file-explorer"; +import FileExplorer from "@/components/file-explorer"; export default function Page() { return ( diff --git a/components/workspace/context-menu.tsx b/components/file-explorer/context-menu.tsx similarity index 100% rename from components/workspace/context-menu.tsx rename to components/file-explorer/context-menu.tsx diff --git a/components/workspace/delete.tsx b/components/file-explorer/delete.tsx similarity index 97% rename from components/workspace/delete.tsx rename to components/file-explorer/delete.tsx index 88ab4b4..abc6e9f 100644 --- a/components/workspace/delete.tsx +++ b/components/file-explorer/delete.tsx @@ -11,7 +11,7 @@ import { import { Button } from "../ui/button"; import { db } from "@/data/db"; import { usePathname } from "next/navigation"; -import { useRefreshFileExplorer } from "./file-explorer"; +import { useRefreshFileExplorer } from "./"; export default function Delete({ type, diff --git a/components/file-explorer/file-explorer-context.ts b/components/file-explorer/file-explorer-context.ts new file mode 100644 index 0000000..9af6362 --- /dev/null +++ b/components/file-explorer/file-explorer-context.ts @@ -0,0 +1,14 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { initialState, useFileExplorerReducer } from "./file-explorer-reducer"; + +const FileExplorerContext = createContext< + ReturnType +>([initialState, () => {}]); + +const useFileExplorerContext = () => { + return useContext(FileExplorerContext); +}; + +export { FileExplorerContext, useFileExplorerContext }; diff --git a/components/file-explorer/file-explorer-node-renderer.tsx b/components/file-explorer/file-explorer-node-renderer.tsx new file mode 100644 index 0000000..6f45a04 --- /dev/null +++ b/components/file-explorer/file-explorer-node-renderer.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { INode } from "react-accessible-treeview"; +import { FileIcon } from "./file-icon"; +import { FileContextMenu, FolderContextMenu, IAction } from "./context-menu"; +import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils"; +import { FolderIcon } from "./folder-icon"; +import { IFileExplorerActionKind } from "./file-explorer-reducer"; +import { useFileExplorerContext } from "./file-explorer-context"; + +type Element = INode; + +export const NodeRenderer = ({ + isBranch, + isExpanded, + element, +}: { + isBranch: boolean; + isExpanded: boolean; + element: Element; +}) => { + const [_, dispatch] = useFileExplorerContext(); + const { name } = element; + + const handleClick = (action: IAction) => { + switch (action) { + case IAction.DELETE: + dispatch({ + type: IFileExplorerActionKind.DELETE, + payload: { + type: isBranch ? "folder" : "file", + path: element.id as string, + }, + }); + break; + case IAction.RENAME: + dispatch({ + type: IFileExplorerActionKind.RENAME, + payload: { + type: isBranch ? "folder" : "file", + path: element.id as string, + }, + }); + break; + case IAction.NEW_FILE: + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "file", + path: element.id as string, + }, + }); + break; + case IAction.NEW_FOLDER: + dispatch({ + type: IFileExplorerActionKind.ADD, + payload: { + type: "folder", + path: element.id as string, + }, + }); + break; + } + }; + + if (name.startsWith(".")) return null; + + return isBranch ? ( + + + + + + {name} + + + ) : ( + + + + + + {name} + + + ); +}; diff --git a/components/file-explorer/file-explorer-reducer.ts b/components/file-explorer/file-explorer-reducer.ts new file mode 100644 index 0000000..64b7cb9 --- /dev/null +++ b/components/file-explorer/file-explorer-reducer.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useReducer } from "react"; + +// An enum with all the types of actions to use in our reducer +export enum IFileExplorerActionKind { + RENAME, + DELETE, + SELECT, + CLOSE_MODAL, + ADD, +} + +type FileOrFolder = "file" | "folder"; + +// An interface for our actions +interface IFileExplorerAction { + type: IFileExplorerActionKind; + payload?: { path?: string; type?: FileOrFolder }; +} + +// An interface for our state +interface IFileExplorerState { + showRename: boolean; + showDelete: boolean; + path?: string; + type?: FileOrFolder; + focusedId?: string; + showAdd: boolean; + addType?: FileOrFolder; +} + +export const initialState: IFileExplorerState = { + showRename: false, + showDelete: false, + showAdd: false, +}; + +// Our reducer function that uses a switch statement to handle our actions +function reducer(state: IFileExplorerState, action: IFileExplorerAction) { + const { type, payload } = action; + switch (type) { + case IFileExplorerActionKind.RENAME: + return { + ...state, + ...(payload || {}), + showRename: true, + }; + case IFileExplorerActionKind.DELETE: + return { + ...state, + ...(payload || {}), + showDelete: true, + }; + case IFileExplorerActionKind.SELECT: + return { + ...state, + ...(payload || {}), + }; + case IFileExplorerActionKind.CLOSE_MODAL: + return { + ...state, + showRename: false, + showDelete: false, + showAdd: false, + }; + case IFileExplorerActionKind.ADD: + return { + ...state, + addType: payload?.type, + showAdd: true, + }; + default: + return state; + } +} + +export const useFileExplorerReducer = () => { + return useReducer(reducer, initialState); +}; diff --git a/components/workspace/file-explorer-top-menu.tsx b/components/file-explorer/file-explorer-top-menu.tsx similarity index 100% rename from components/workspace/file-explorer-top-menu.tsx rename to components/file-explorer/file-explorer-top-menu.tsx diff --git a/components/workspace/file-explorer.css b/components/file-explorer/file-explorer.css similarity index 100% rename from components/workspace/file-explorer.css rename to components/file-explorer/file-explorer.css diff --git a/components/workspace/file-icon.tsx b/components/file-explorer/file-icon.tsx similarity index 100% rename from components/workspace/file-icon.tsx rename to components/file-explorer/file-icon.tsx diff --git a/components/file-explorer/folder-icon.tsx b/components/file-explorer/folder-icon.tsx new file mode 100644 index 0000000..4ca3308 --- /dev/null +++ b/components/file-explorer/folder-icon.tsx @@ -0,0 +1,8 @@ +import { FolderOpen, FolderClosed } from "lucide-react"; + +export const FolderIcon = ({ isOpen }: { isOpen: boolean }) => + isOpen ? ( + + ) : ( + + ); diff --git a/components/workspace/file-explorer.tsx b/components/file-explorer/index.tsx similarity index 53% rename from components/workspace/file-explorer.tsx rename to components/file-explorer/index.tsx index 5cf7344..44c7d23 100644 --- a/components/workspace/file-explorer.tsx +++ b/components/file-explorer/index.tsx @@ -4,16 +4,17 @@ import { db } from "@/data/db"; import { usePathname } from "next/navigation"; import useSWR, { mutate } from "swr"; import { FileExplorerTopMenu } from "./file-explorer-top-menu"; -import TreeView, { flattenTree, INode } from "react-accessible-treeview"; -import { FolderOpen, FolderClosed } from "lucide-react"; -import { FileIcon } from "./file-icon"; +import TreeView, { flattenTree } from "react-accessible-treeview"; import "./file-explorer.css"; import { useSetSearchParams } from "@/lib/set-search-params"; -import { FileContextMenu, FolderContextMenu, IAction } from "./context-menu"; -import { IFlatMetadata } from "react-accessible-treeview/dist/TreeView/utils"; -import { useReducer } from "react"; import Rename from "./rename"; import Delete from "./delete"; +import { + IFileExplorerActionKind, + useFileExplorerReducer, +} from "./file-explorer-reducer"; +import { NodeRenderer } from "./file-explorer-node-renderer"; +import { FileExplorerContext } from "./file-explorer-context"; interface TreeViewElement { name: string; @@ -48,85 +49,12 @@ function convert(data: string[]) { return flattenTree(root); } -// An enum with all the types of actions to use in our reducer -enum IFileExplorerActionKind { - RENAME, - DELETE, - SELECT, - CLOSE_MODAL, - ADD, -} - -type FileOrFolder = "file" | "folder"; - -// An interface for our actions -interface IFileExplorerAction { - type: IFileExplorerActionKind; - payload?: { path?: string; type?: FileOrFolder }; -} - -// An interface for our state -interface IFileExplorerState { - showRename: boolean; - showDelete: boolean; - path?: string; - type?: FileOrFolder; - focusedId?: string; - showAdd: boolean; - addType?: FileOrFolder; -} - -const initialState: IFileExplorerState = { - showRename: false, - showDelete: false, - showAdd: false, -}; - -// Our reducer function that uses a switch statement to handle our actions -function reducer(state: IFileExplorerState, action: IFileExplorerAction) { - const { type, payload } = action; - switch (type) { - case IFileExplorerActionKind.RENAME: - return { - ...state, - ...(payload || {}), - showRename: true, - }; - case IFileExplorerActionKind.DELETE: - return { - ...state, - ...(payload || {}), - showDelete: true, - }; - case IFileExplorerActionKind.SELECT: - return { - ...state, - ...(payload || {}), - }; - case IFileExplorerActionKind.CLOSE_MODAL: - return { - ...state, - showRename: false, - showDelete: false, - showAdd: false, - }; - case IFileExplorerActionKind.ADD: - return { - ...state, - addType: payload?.type, - showAdd: true, - }; - default: - return state; - } -} - const FileExplorer = () => { const pathname = usePathname(); const setSearchParams = useSetSearchParams(); const refreshFileExplorer = useRefreshFileExplorer(); - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useFileExplorerReducer(); const { data } = useSWR(`file-explorer-${pathname}`, async () => { const files = await db.files.filter((file) => @@ -143,7 +71,7 @@ const FileExplorer = () => { if (!data) return

Loading...

; return ( - <> +
{ isBranch={isBranch} isExpanded={isExpanded} element={element} - handleRename={() => - dispatch({ - type: IFileExplorerActionKind.RENAME, - payload: { - type: isBranch ? "folder" : "file", - path: element.id as string, - }, - }) - } - handleDelete={() => - dispatch({ - type: IFileExplorerActionKind.DELETE, - payload: { - type: isBranch ? "folder" : "file", - path: element.id as string, - }, - }) - } - handleNewFile={() => - dispatch({ - type: IFileExplorerActionKind.ADD, - payload: { - type: "file", - path: element.id as string, - }, - }) - } - handleNewFolder={() => - dispatch({ - type: IFileExplorerActionKind.ADD, - payload: { - type: "folder", - path: element.id as string, - }, - }) - } /> {state.showAdd && state.path === element.id ? ( { dispatch({ type: IFileExplorerActionKind.CLOSE_MODAL }) } /> - + ); }; -export type Element = INode; - -const NodeRenderer = ({ - isBranch, - isExpanded, - element, - handleRename, - handleDelete, - handleNewFile, - handleNewFolder, -}: { - isBranch: boolean; - isExpanded: boolean; - element: Element; - handleRename: () => void; - handleDelete: () => void; - handleNewFile: () => void; - handleNewFolder: () => void; -}) => { - const { name } = element; - - const handleClick = (action: IAction) => { - switch (action) { - case IAction.DELETE: - handleDelete(); - break; - case IAction.RENAME: - handleRename(); - break; - case IAction.NEW_FILE: - handleNewFile(); - break; - case IAction.NEW_FOLDER: - handleNewFolder(); - break; - } - }; - - if (name.startsWith(".")) return null; - - return isBranch ? ( - - - - - - {name} - - - ) : ( - - - - - - {name} - - - ); -}; - -const FolderIcon = ({ isOpen }: { isOpen: boolean }) => - isOpen ? ( - - ) : ( - - ); - /** * * @returns Function to refresh the file explorer in the current view diff --git a/components/workspace/new-file-form.tsx b/components/file-explorer/new-file-form.tsx similarity index 97% rename from components/workspace/new-file-form.tsx rename to components/file-explorer/new-file-form.tsx index 402c7de..d7e837e 100644 --- a/components/workspace/new-file-form.tsx +++ b/components/file-explorer/new-file-form.tsx @@ -18,7 +18,7 @@ import { Input } from "@/components/ui/input"; import { usePathname } from "next/navigation"; import { db } from "@/data/db"; import { Loader2 } from "lucide-react"; -import { useRefreshFileExplorer } from "./file-explorer"; +import { useRefreshFileExplorer } from "./"; const FormSchema = z.object({ path: z.string(), diff --git a/components/workspace/new-file.tsx b/components/file-explorer/new-file.tsx similarity index 100% rename from components/workspace/new-file.tsx rename to components/file-explorer/new-file.tsx diff --git a/components/workspace/rename-form.tsx b/components/file-explorer/rename-form.tsx similarity index 88% rename from components/workspace/rename-form.tsx rename to components/file-explorer/rename-form.tsx index 745f5bd..17bc5c7 100644 --- a/components/workspace/rename-form.tsx +++ b/components/file-explorer/rename-form.tsx @@ -18,7 +18,7 @@ import { Input } from "@/components/ui/input"; import { usePathname } from "next/navigation"; import { db } from "@/data/db"; import { Loader2 } from "lucide-react"; -import { useRefreshFileExplorer } from "./file-explorer"; +import { useRefreshFileExplorer } from "./"; const FormSchema = z.object({ path: z.string(), @@ -61,13 +61,18 @@ export function RenameForm({ await db.files.delete(currentKey); } else { const currentKey = `${pathname}/${encodeURIComponent(path)}`; - const currentFiles = await db.files.filter(file => file.path.startsWith(currentKey)).toArray(); + const currentFiles = await db.files + .filter((file) => file.path.startsWith(currentKey)) + .toArray(); const newKey = `${pathname}/${encodeURIComponent(data.path)}`; - const newFiles = currentFiles.map(i => ({...i, path: i.path.replace(currentKey, newKey)})); + const newFiles = currentFiles.map((i) => ({ + ...i, + path: i.path.replace(currentKey, newKey), + })); await db.files.bulkAdd(newFiles); - await db.files.bulkDelete(currentFiles.map(i => i.path)); + await db.files.bulkDelete(currentFiles.map((i) => i.path)); } await refreshFileExplorer(); diff --git a/components/workspace/rename.tsx b/components/file-explorer/rename.tsx similarity index 100% rename from components/workspace/rename.tsx rename to components/file-explorer/rename.tsx From 8df21620107c0b101a71d67ec3f2c05025aff1d8 Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Mon, 2 Sep 2024 12:50:01 +0800 Subject: [PATCH 11/11] fix: import --- components/workspace/upload-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/workspace/upload-modal.tsx b/components/workspace/upload-modal.tsx index 69b029a..698d7c5 100644 --- a/components/workspace/upload-modal.tsx +++ b/components/workspace/upload-modal.tsx @@ -15,7 +15,7 @@ import { db, FileContent } from "@/data/db"; import { useCallback, useState } from "react"; import { useDropzone, DropzoneOptions } from "react-dropzone"; import { usePathname } from "next/navigation"; -import { useRefreshFileExplorer } from "./file-explorer"; +import { useRefreshFileExplorer } from "@/components/file-explorer"; import { useLoadFiles } from "../webcontainer/use-load-files"; import { Tooltip } from "../tooltip";