Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion app/components/FileManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@
deleteFolderResource,
uploadFilesToContainer,
uploadFolderFilesToContainer,
FolderUploadFile,

Check warning on line 46 in app/components/FileManager.tsx

View workflow job for this annotation

GitHub Actions / eslint

'FolderUploadFile' is defined but never used
processDragDropItems,
hasFiles as hasFilesInDrag,
isUnsupportedFolderDrag,
ensureTrailingSlash,
} from "../lib/helpers";
import { shareResourceWithAcp } from "../lib/helpers/acpUtils";
import {
Expand Down Expand Up @@ -510,7 +511,7 @@
let fetchFn: typeof fetch;
try {
({ fetch: fetchFn } = getAuthenticatedSession());
} catch (error) {

Check warning on line 514 in app/components/FileManager.tsx

View workflow job for this annotation

GitHub Actions / eslint

'error' is defined but never used
toast.error("Not authenticated");
return;
}
Expand Down Expand Up @@ -774,6 +775,33 @@
}
};

// 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 (
<AuthWrapper>
Expand Down Expand Up @@ -844,8 +872,9 @@
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
activeTab="my-storages"
currentContainerUrl={containerUrlToBrowse}
storages={storages}
onFolderNavigate={handleFolderNavigate}
onNewFolderClick={() => setShowNewFolderDialog(true)}
onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)}
onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)}
Expand Down
195 changes: 195 additions & 0 deletions app/components/FolderTree.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(new Set());
const [childrenByUrl, setChildrenByUrl] = useState<Record<string, FolderTreeChild[]>>({});
const [loadingUrls, setLoadingUrls] = useState<Set<string>>(new Set());
const [errorByUrl, setErrorByUrl] = useState<Record<string, string>>({});

// 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 (
<li key={nodeUrl}>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1 text-sm ${isCurrent ? "bg-[#F3EDFF] text-black font-medium" : "text-gray-700 hover:bg-gray-100"
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<button
type="button"
onClick={() => void toggleExpand(nodeUrl)}
className="rounded p-0.5 hover:bg-gray-200"
aria-label={isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
)}
</button>

<button
type="button"
onClick={() => onNavigate(nodeUrl)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
title={node.name}
aria-current={isCurrent ? "page" : undefined}
>
<FolderIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{node.name}</span>
</button>
</div>

{isExpanded && (
<ul className="space-y-0.5">
{isLoading && (
<li
className="px-2 py-1 text-xs text-gray-500"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
aria-live="polite"
>
Loading...
</li>
)}

{!isLoading && hasError && (
<li
className="px-2 py-1 text-xs text-red-600"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
>
Failed to load folders
</li>
)}

{!isLoading && !hasError && children.length === 0 && (
<li
className="px-2 py-1 text-xs text-gray-500"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
>
No folders
</li>
)}

{!isLoading && !hasError && children.map((child) => renderNode(child, depth + 1))}
</ul>
)}
</li>
)
}, [
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 <p className="px-3 py-2 text-sm text-gray-500">No storages found</p>
}

return (
<ul className="space-y-0.5" aria-label="My Storages">
{rootNodes.map((node) => renderNode(node, 0))}
</ul>
);
}
49 changes: 19 additions & 30 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,8 +23,9 @@ interface SidebarProps {
export default function Sidebar({
isOpen = true,
onClose,
activeTab = "my-storages",
currentContainerUrl,
storages,
onFolderNavigate,
onNewFolderClick,
onFileUploadClick,
onFolderUploadClick,
Expand All @@ -40,9 +44,6 @@ export default function Sidebar({
refs: [sidebarRef],
});

const navigationTabs = [
{ id: "my-storages", label: "My Storages" },
];

return (
<>
Expand All @@ -58,9 +59,8 @@ export default function Sidebar({
{/* Sidebar */}
<aside
ref={sidebarRef}
className={`fixed left-0 top-0 z-50 h-full w-64 border-r border-gray-200 bg-white shadow-lg transition-transform lg:relative lg:z-auto lg:shadow-none lg:translate-x-0 ${
isMobileOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
}`}
className={`fixed left-0 top-0 z-50 h-full w-64 border-r border-gray-200 bg-white shadow-lg transition-transform lg:relative lg:z-auto lg:shadow-none lg:translate-x-0 ${isMobileOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
}`}
>
<nav className="flex h-full flex-col p-2" aria-label="Navigation">
{/* Header with close button on mobile */}
Expand All @@ -84,28 +84,17 @@ export default function Sidebar({
onFileUploadClick={onFileUploadClick}
onFolderUploadClick={onFolderUploadClick}
/>

{/* Navigation Tabs */}
<ul className="space-y-1" role="list">
{navigationTabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<li key={tab.id}>
<button
type="button"
className={`cursor-pointer w-full rounded-md px-3 py-2 text-left text-sm font-medium transition-colors ${
isActive
? "bg-[#F3EDFF] text-black"
: "text-gray-700 hover:bg-gray-100"
}`}
aria-current={isActive ? "page" : undefined}
>
{tab.label}
</button>
</li>
);
})}
</ul>

<div className="mt-2 flex-1 overflow-y-auto">
<p className="px-3 py-1 text-xs font-medium text-gray-500">My Storages</p>
{storages && onFolderNavigate ? (
<FolderTree
storages={storages}
currentFolderUrl={currentContainerUrl}
onNavigate={onFolderNavigate}
/>
) : null}
</div>

{/* Footer links - pushed to bottom */}
<div className="mt-auto border-t border-gray-200 pt-4">
Expand Down
Loading
Loading