From 766579c0db1b68137f23f9b593be64c24572650b Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Mon, 27 Jul 2026 16:13:09 +0100 Subject: [PATCH 1/6] Add folder tree path helpers for sidebar navigation --- app/lib/helpers/folderTreeUtils.ts | 64 ++++++++++++++++++++++++++++++ app/lib/helpers/index.ts | 1 + 2 files changed, 65 insertions(+) create mode 100644 app/lib/helpers/folderTreeUtils.ts diff --git a/app/lib/helpers/folderTreeUtils.ts b/app/lib/helpers/folderTreeUtils.ts new file mode 100644 index 0000000..29af640 --- /dev/null +++ b/app/lib/helpers/folderTreeUtils.ts @@ -0,0 +1,64 @@ +import { ensureTrailingSlash } from "./copyUtils"; + + +/** + * Returns true when folderUrl is the storage root or a folder inside it. + */ +export function isFolderUnderStorage( + folderUrl: string, + storageRootUrl: string, +): boolean { + const folder = ensureTrailingSlash(folderUrl); + const storage = ensureTrailingSlash(storageRootUrl); + return folder === storage || folder.startsWith(storage); +} + +/** + * Compares two folder URLs after normalizing trailing slashes. + */ +export function folderUrlsEqual(a: string, b: string): boolean { + return ensureTrailingSlash(a) === ensureTrailingSlash(b); +} + +/** + * Folder container URLs that must be expanded so currentFolderUrl is visible in the tree. + * Used when navigation comes from the main view or breadcrumb. + */ +export function getAncestorFolderUrls( + currentFolderUrl: string, + storageRootUrl: string, +): string[] { + const current = ensureTrailingSlash(currentFolderUrl); + const storage = ensureTrailingSlash(storageRootUrl); + + if (!current.startsWith(storage)) { + return []; + } + + if (current === storage) { + return []; + } + + const storageUrl = new URL(storage); + const storagePath = storageUrl.pathname; + const currentPath = new URL(current).pathname; + + const relative = currentPath + .slice(storagePath.length) + .replace(/^\/|\/$/g, ""); + + if (!relative) { + return []; + } + + const segments = relative.split("/").filter(Boolean); + const ancestors: string[] = [storage]; + + for (let i = 0; i < segments.length - 1; i++) { + const pathParts = segments.slice(0, i + 1); + const path = `${storagePath}${pathParts.join("/")}/`; + ancestors.push(`${storageUrl.origin}${path}`); + } + + return ancestors; +} diff --git a/app/lib/helpers/index.ts b/app/lib/helpers/index.ts index e32dc7f..93efa98 100644 --- a/app/lib/helpers/index.ts +++ b/app/lib/helpers/index.ts @@ -14,4 +14,5 @@ export * from "./uploadUtils"; export * from "./urlStateUtils"; export * from "./dragDropUtils"; export * from "./contactUtils"; +export * from "./folderTreeUtils"; From b649f77a23d301a1812a91fbb9f47514f8890abe Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 28 Jul 2026 10:02:15 +0100 Subject: [PATCH 2/6] Add helper function to fetch child folders for sidebar tree --- app/lib/helpers/folderTreeUtils.ts | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/lib/helpers/folderTreeUtils.ts b/app/lib/helpers/folderTreeUtils.ts index 29af640..5ab52fb 100644 --- a/app/lib/helpers/folderTreeUtils.ts +++ b/app/lib/helpers/folderTreeUtils.ts @@ -1,5 +1,16 @@ +import { + getSolidDataset, + getContainedResourceUrlAll, + isContainer, + UrlString, +} from "@inrupt/solid-client"; import { ensureTrailingSlash } from "./copyUtils"; +import { extractNameFromUrl } from "./urlUtils"; +export type FolderTreeChild = { + url: string; + name: string; +}; /** * Returns true when folderUrl is the storage root or a folder inside it. @@ -62,3 +73,35 @@ export function getAncestorFolderUrls( return ancestors; } + +/** + * List direct child folders of a container. Files are omitted. + */ +export async function fetchFolderChildren( + containerUrl: string, + fetchFn: typeof fetch, +): Promise { + const url = ensureTrailingSlash(containerUrl); + const dataset = await getSolidDataset(url as UrlString, { fetch: fetchFn }); + const contained = getContainedResourceUrlAll(dataset); + + const folders: FolderTreeChild[] = []; + + for (const resourceUrl of contained) { + if (resourceUrl.endsWith(".acl") || resourceUrl.endsWith(".meta")) { + continue; + } + if (!isContainer(resourceUrl)) { + continue; + } + + const folderUrl = ensureTrailingSlash(resourceUrl); + folders.push({ + url: folderUrl, + name: extractNameFromUrl(folderUrl), + }); + } + + folders.sort((a, b) => a.name.localeCompare(b.name)); + return folders; +} From 83f193dd08476fc9c50764a9d88fb938d59452b9 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 28 Jul 2026 12:30:14 +0100 Subject: [PATCH 3/6] Add folder tree component --- app/components/FolderTree.tsx | 195 ++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 app/components/FolderTree.tsx 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))} +
    + ); +} From c8fb92a9e2571e0d17846cbbc88faf7880bbb1e9 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 28 Jul 2026 14:03:58 +0100 Subject: [PATCH 4/6] Show folder tree in sidebar --- app/components/Sidebar.tsx | 49 +++++++++++++++----------------------- 1 file changed, 19 insertions(+), 30 deletions(-) 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 */}