diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index 04f6f82..75d1eb7 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -47,6 +47,7 @@ import { processDragDropItems, hasFiles as hasFilesInDrag, isUnsupportedFolderDrag, + ensureTrailingSlash, } from "../lib/helpers"; import { shareResourceWithAcp } from "../lib/helpers/acpUtils"; import { @@ -774,6 +775,33 @@ export default function FileManager() { } }; + // Navigate from the sidebar folder tree (storage root or subfolder). + const handleFolderNavigate = (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + const matchingStorage = storages.find( + (s) => + normalizedUrl === ensureTrailingSlash(s.url) || + normalizedUrl.startsWith(ensureTrailingSlash(s.url)), + ); + + if (!matchingStorage) { + return; + } + + setSelectedStorageId(matchingStorage.id); + setSelectedFileIds([]); + + if (normalizedUrl === ensureTrailingSlash(matchingStorage.url)) { + setCurrentPath("/"); + updateUrl(matchingStorage.url, true); + } else { + setCurrentPath(normalizedUrl); + updateUrl(normalizedUrl, true); + } + + setSidebarOpen(false); + }; + if (isLoadingStorages) { return ( @@ -844,8 +872,9 @@ export default function FileManager() { setSidebarOpen(false)} - activeTab="my-storages" currentContainerUrl={containerUrlToBrowse} + storages={storages} + onFolderNavigate={handleFolderNavigate} onNewFolderClick={() => setShowNewFolderDialog(true)} onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx new file mode 100644 index 0000000..8ada3e3 --- /dev/null +++ b/app/components/FolderTree.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { ChevronRightIcon, ChevronDownIcon, FolderIcon } from "@heroicons/react/24/outline"; +import { SolidStorage } from "../lib/hooks/useSolidStorages"; +import { fetchFolderChildren, FolderTreeChild, folderUrlsEqual, ensureTrailingSlash, getAuthenticatedSession } from "../lib/helpers"; + +interface FolderTreeProps { + storages: SolidStorage[]; + currentFolderUrl?: string | null; + onNavigate: (folderUrl: string) => void; +} + +export default function FolderTree({ + storages, + currentFolderUrl, + onNavigate +}: FolderTreeProps) { + const [expandedUrls, setExpandedUrls] = useState>(new Set()); + const [childrenByUrl, setChildrenByUrl] = useState>({}); + const [loadingUrls, setLoadingUrls] = useState>(new Set()); + const [errorByUrl, setErrorByUrl] = useState>({}); + + // Keep the current folder URL in one shape so highlight checks stay reliable. + const normalizedCurrentFolderUrl = useMemo(() => ( + currentFolderUrl ? ensureTrailingSlash(currentFolderUrl) : null + ), [currentFolderUrl]); + + // Fetch child folders for one container and store them in the cache. + const loadChildren = useCallback(async (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + + if (childrenByUrl[normalizedUrl]) { + return; + } + + setLoadingUrls((prev) => { + const next = new Set(prev); + next.add(normalizedUrl); + return next; + }); + + setErrorByUrl((prev) => { + if (!(normalizedUrl in prev)) return prev; + const next = { ...prev }; + delete next[normalizedUrl]; + return next; + }); + + try { + const { fetch } = getAuthenticatedSession(); + const children = await fetchFolderChildren(normalizedUrl, fetch); + setChildrenByUrl((prev) => ({ ...prev, [normalizedUrl]: children })); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load folders"; + setErrorByUrl((prev) => ({ ...prev, [normalizedUrl]: message })); + } finally { + setLoadingUrls((prev) => { + const next = new Set(prev); + next.delete(normalizedUrl); + return next; + }); + } + }, [childrenByUrl]); + + // Open or close a folder branch. Fetch children only when opening. + const toggleExpand = useCallback(async (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + + const isExpanded = expandedUrls.has(normalizedUrl); + if (isExpanded) { + setExpandedUrls((prev) => { + const next = new Set(prev); + next.delete(normalizedUrl); + return next; + }); + return; + } + + setExpandedUrls((prev) => { + const next = new Set(prev); + next.add(normalizedUrl); + return next; + }); + + await loadChildren(normalizedUrl); + }, [expandedUrls, loadChildren]); + + // Render one folder row and its nested children when expanded. + const renderNode = useCallback((node: FolderTreeChild, depth: number) => { + const nodeUrl = ensureTrailingSlash(node.url); + const isExpanded = expandedUrls.has(nodeUrl); + const isLoading = loadingUrls.has(nodeUrl); + const children = childrenByUrl[nodeUrl] || []; + const hasError = Boolean(errorByUrl[nodeUrl]); + const isCurrent = normalizedCurrentFolderUrl != null && folderUrlsEqual(normalizedCurrentFolderUrl, nodeUrl); + + return ( +
  • +
    + + + +
    + + {isExpanded && ( +
      + {isLoading && ( +
    • + Loading... +
    • + )} + + {!isLoading && hasError && ( +
    • + Failed to load folders +
    • + )} + + {!isLoading && !hasError && children.length === 0 && ( +
    • + No folders +
    • + )} + + {!isLoading && !hasError && children.map((child) => renderNode(child, depth + 1))} +
    + )} +
  • + ) + }, [ + childrenByUrl, + errorByUrl, + expandedUrls, + loadingUrls, + normalizedCurrentFolderUrl, + onNavigate, + toggleExpand, + ], + ); + + // Turn storage roots into the same shape used by child folder nodes. + const rootNodes: FolderTreeChild[] = useMemo( + () => + storages.map((storage) => ({ + url: ensureTrailingSlash(storage.url), + name: storage.name || storage.url, + })), [storages], + ); + + if (rootNodes.length === 0) { + return

    No storages found

    + } + + return ( +
      + {rootNodes.map((node) => renderNode(node, 0))} +
    + ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 4faba7e..000c5ae 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -5,13 +5,16 @@ import Button from "./shared/Button"; import { XMarkIcon } from "@heroicons/react/24/outline"; import NewMenuButton from "./NewMenuButton"; import GitHubLinks from "./shared/GitHubLinks"; +import FolderTree from "./FolderTree"; import { useClickOutside } from "../lib/hooks"; +import { SolidStorage } from "../lib/hooks/useSolidStorages"; interface SidebarProps { isOpen?: boolean; onClose?: () => void; - activeTab?: string; currentContainerUrl?: string | null; + storages?: SolidStorage[]; + onFolderNavigate?: (folderUrl: string) => void; onNewFolderClick?: () => void; onFileUploadClick?: () => void; onFolderUploadClick?: () => void; @@ -20,8 +23,9 @@ interface SidebarProps { export default function Sidebar({ isOpen = true, onClose, - activeTab = "my-storages", currentContainerUrl, + storages, + onFolderNavigate, onNewFolderClick, onFileUploadClick, onFolderUploadClick, @@ -40,9 +44,6 @@ export default function Sidebar({ refs: [sidebarRef], }); - const navigationTabs = [ - { id: "my-storages", label: "My Storages" }, - ]; return ( <> @@ -58,9 +59,8 @@ export default function Sidebar({ {/* Sidebar */}