diff --git a/app/components/FolderTree.tsx b/app/components/FolderTree.tsx index 3b35d49..aa7179a 100644 --- a/app/components/FolderTree.tsx +++ b/app/components/FolderTree.tsx @@ -1,10 +1,9 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useMemo} from "react"; import { ChevronRight, ChevronDown, Folder } from "lucide-react"; import { SolidStorage } from "../lib/hooks/useSolidStorages"; -import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash, getAuthenticatedSession, fetchContainerListing, foldersFromListing } from "../lib/helpers"; -import { getContainerListing, loadContainerListing, subscribeContainerCache, getContainerCacheVersion } from "../lib/cache"; +import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash } from "../lib/helpers"; import { SidebarMenu, SidebarMenuButton, @@ -13,6 +12,7 @@ import { SidebarMenuSubItem, SidebarMenuSkeleton, } from "@/components/ui/sidebar"; +import { useFolderTree } from "../lib/hooks/useFolderTree"; interface FolderTreeProps { storages: SolidStorage[]; @@ -25,116 +25,16 @@ export default function FolderTree({ currentFolderUrl, onNavigate }: FolderTreeProps) { - const [expandedUrls, setExpandedUrls] = useState>(new Set()); - const [loadingUrls, setLoadingUrls] = useState>(new Set()); - const [errorByUrl, setErrorByUrl] = useState>({}); - - const cacheVersion = useSyncExternalStore( - subscribeContainerCache, - getContainerCacheVersion, - getContainerCacheVersion, - ) - - // Children come from the shared cache, not a local copy. - // cacheVersion makes this recompute when listings are written or invalidated. - const childrenByUrl = useMemo(() => { - // cacheVersion is read so listings recompute after cache writes/invalidation. - void cacheVersion; - const next: Record = {}; - for (const url of expandedUrls) { - const cached = getContainerListing(url); - if (cached) { - next[url] = foldersFromListing(cached); - } - } - return next; - }, [cacheVersion, expandedUrls]) - - const expandedUrlsRef = useRef(expandedUrls); - expandedUrlsRef.current = expandedUrls; + const { expandedUrls, loadingUrls, errorByUrl, childrenByUrl, toggleExpand } = + useFolderTree(); // Keep the current folder URL in one shape so highlight checks stay reliable. const normalizedCurrentFolderUrl = useMemo(() => ( currentFolderUrl ? ensureTrailingSlash(currentFolderUrl) : null ), [currentFolderUrl]); - // Load child folders via the shared container cache (same data as main browse). - const loadChildren = useCallback(async (folderUrl: string) => { - const normalizedUrl = ensureTrailingSlash(folderUrl); - - if (getContainerListing(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(); - await loadContainerListing( - normalizedUrl, - () => fetchContainerListing(normalizedUrl, fetch), - ); - } 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; - }); - } - }, []); - - - // After cache invalidation, refetch any expanded folder that no longer has a listing. - useEffect(() => { - return subscribeContainerCache(() => { - for (const url of expandedUrlsRef.current) { - if (!getContainerListing(url)) { - void loadChildren(url) - } - } - }) - }, [loadChildren]) - - // 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) => { + function renderNode(node: FolderTreeChild) { const nodeUrl = ensureTrailingSlash(node.url); const isExpanded = expandedUrls.has(nodeUrl); const isLoading = loadingUrls.has(nodeUrl); @@ -194,16 +94,7 @@ export default function FolderTree({ )} ); - }, [ - childrenByUrl, - errorByUrl, - expandedUrls, - loadingUrls, - normalizedCurrentFolderUrl, - onNavigate, - toggleExpand, - ], - ); + } // Turn storage roots into the same shape used by child folder nodes. const rootNodes: FolderTreeChild[] = useMemo( diff --git a/app/components/FolderTreePicker.tsx b/app/components/FolderTreePicker.tsx new file mode 100644 index 0000000..a88fce0 --- /dev/null +++ b/app/components/FolderTreePicker.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ChevronDown, ChevronRight, Folder } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { + FolderTreeChild, + ensureTrailingSlash, + folderUrlsEqual, + getAncestorFolderUrls +} from "../lib/helpers"; +import { useFolderTree, type UseFolderTreeResult } from "../lib/hooks"; + +interface FolderTreePickerProps { + roots: FolderTreeChild[]; + selectedUrl: string | null; + onSelect: (folderUrl: string) => void; + /** + * A folder that may be browsed but cannot be chosen, e.g the one the resource is + * already in. + */ + currentUrl?: string | null; +} + +export default function FolderTreePicker({ + roots, + selectedUrl, + onSelect, + currentUrl, +}: FolderTreePickerProps) { + const tree = useFolderTree(); + const { expandPath } = tree; + + // Expand the path to the current folder so the user can see where they are. + const revealUrls = [ + ...new Set([ + ...roots.map((root) => ensureTrailingSlash(root.url)), + ...(currentUrl ? roots.flatMap((root) => + getAncestorFolderUrls(currentUrl, root.url)) : []), + ]), + ]; + + // make sure the path to the current folder is expanded once when the component mounts + const revealKey = revealUrls.join("|"); + + useEffect(() => { + if (!revealKey) { + return; + } + void expandPath(revealKey.split("|")); + }, [revealKey, expandPath]); + + if (roots.length === 0) { + return ( +

+ No folders available +

+ ); + } + + return ( +
    + {roots.map((root) => ( + + ))} +
+ ); +} + +interface FolderTreeNodeProps { + node: FolderTreeChild; + depth: number; + /** Shared expand/load state, passed whole so the recursion stays readable. */ + tree: UseFolderTreeResult; + selectedUrl: string | null; + currentUrl?: string | null; + onSelect: (folderUrl: string) => void; +} + +/** One folder row, plus its children when the branch is open. */ +function FolderTreeNode({ + node, + depth, + tree, + selectedUrl, + currentUrl, + onSelect, +}: FolderTreeNodeProps) { + const nodeUrl = ensureTrailingSlash(node.url); + const isExpanded = tree.expandedUrls.has(nodeUrl); + const isLoading = tree.loadingUrls.has(nodeUrl); + const hasError = Boolean(tree.errorByUrl[nodeUrl]); + const children = tree.childrenByUrl[nodeUrl]; + const isSelected = + selectedUrl !== null && folderUrlsEqual(selectedUrl, nodeUrl); + const isCurrent = + currentUrl != null && folderUrlsEqual(currentUrl, nodeUrl); + + const rowRef = useRef(null); + + // Scroll the current folder into view. + useEffect(() => { + if (isCurrent) { + rowRef.current?.scrollIntoView({ block: "nearest" }); + } + }, [isCurrent]); + + const childIndent = `${(depth + 1) * 1.25 + 0.5}rem`; + + return ( +
  • +
    + + + +
    + + {isExpanded && ( +
      + {isLoading && ( +
    • + +
    • + )} + + {!isLoading && hasError && ( +
    • + Failed to load folders +
    • + )} + + {!isLoading && !hasError && children?.length === 0 && ( +
    • + No subfolders +
    • + )} + + {!isLoading && + !hasError && + children?.map((child) => ( + + ))} +
    + )} +
  • + ); +} diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index fa6ce12..b1d6c5c 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Input } from "@/components/ui/input"; import { toast } from "@/components/ui/toast"; import { FileItemData } from "./FileItem"; +import type { SolidStorage } from "../lib/hooks/useSolidStorages"; import { moveFileResource, moveFileWithConflictChoice, @@ -21,37 +22,42 @@ import { getAuthenticatedSession, getParentContainerUrl, ensureTrailingSlash, - folderUrlsEqual, - fetchContainerListing, describeMoveError, describeMoveResult, + type FolderTreeChild, type MoveResult, } from "../lib/helpers"; import { useNameConflictPrompt } from "./useNameConflictPrompt"; -import { loadContainerListing } from "../lib/cache"; -import { Folder, AlertTriangle } from "lucide-react"; +import { Folder, FolderInput } from "lucide-react"; +import FolderTreePicker from "./FolderTreePicker"; interface MoveDialogProps { isOpen: boolean; onClose: () => void; file: FileItemData | null; - availableFolders: FileItemData[]; + storages: SolidStorage[]; currentLocationUrl: string; onMoved?: (destinationUrl: string) => void; } +function folderPathLabel(folderUrl: string): string { + try { + return decodeURIComponent(new URL(folderUrl).pathname.replace(/\/$/, "")) || ""; + } catch { + return folderUrl; + } +} + export default function MoveDialog({ isOpen, onClose, file, - availableFolders, + storages, currentLocationUrl, onMoved, }: MoveDialogProps) { const [selectedFolderUrl, setSelectedFolderUrl] = useState(null); const [isMoving, setIsMoving] = useState(false); - const [isLoadingFolders, setIsLoadingFolders] = useState(false); - const [allFolders, setAllFolders] = useState([]); const [newName, setNewName] = useState(""); const nameInputId = useId(); const { askConflictChoice, conflictDialog } = useNameConflictPrompt(); @@ -64,78 +70,13 @@ export default function MoveDialog({ ? "A name cannot contain / or \\" : null; - // Recursively fetch all folders from a storage - const fetchAllFolders = async (containerUrl: string, fetchFn: typeof fetch): Promise => { - const folders: FileItemData[] = []; - const visited = new Set(); - - const traverse = async (url: string): Promise => { - const normalizedUrl = ensureTrailingSlash(url); - if (visited.has(normalizedUrl)) { - return; - } - visited.add(normalizedUrl); - - try { - const listing = await loadContainerListing(normalizedUrl, () => - fetchContainerListing(normalizedUrl, fetchFn), - ); - - for (const item of listing) { - if (item.type !== "folder") { - continue; - } - - folders.push(item); - await traverse(item.url); - } - } catch (error) { - // Skip folders we can't access - console.warn(`Could not access folder ${url}:`, error); - } - }; - - await traverse(containerUrl); - return folders; - }; - useEffect(() => { if (isOpen && file) { setSelectedFolderUrl(null); setIsMoving(false); - setIsLoadingFolders(true); - setAllFolders([]); setNewName(file.name); - - // Find the storage root for this file - const fileStorage = availableFolders.find( - (folder) => folder.type === "folder" && file.url.startsWith(folder.url) - ); - - if (fileStorage) { - const loadFolders = async () => { - try { - const { fetch: fetchFn } = getAuthenticatedSession(); - const folders = await fetchAllFolders(fileStorage.url, fetchFn); - // Include the storage root itself - setAllFolders([fileStorage, ...folders]); - } catch (error) { - console.error("Failed to load folders:", error); - // Fallback to availableFolders if recursive fetch fails - setAllFolders(availableFolders.filter(f => f.type === "folder")); - } finally { - setIsLoadingFolders(false); - } - }; - - loadFolders(); - } else { - // Fallback to availableFolders if we can't find the storage - setAllFolders(availableFolders.filter(f => f.type === "folder")); - setIsLoadingFolders(false); - } } - }, [isOpen, file, availableFolders]); + }, [isOpen, file]); const handleMove = async () => { if (!file || !selectedFolderUrl) { @@ -205,22 +146,34 @@ export default function MoveDialog({ if (!file) return null; - // Filter out the current folder (where the file currently is). - // Compare normalised URLs: currentLocationUrl can arrive without a - // trailing slash from the ?url= param, while listing URLs always have one. - const filteredFolders = allFolders.filter( - (folder) => folder.type === "folder" && !folderUrlsEqual(folder.url, currentLocationUrl) + // Move is scoped to the storage the file already lives in. + const fileStorage = storages.find( + (storage) => file.url.startsWith(ensureTrailingSlash(storage.url)), ); + const roots: FolderTreeChild[] = fileStorage + ? [{ + url: ensureTrailingSlash(fileStorage.url), + name: fileStorage.name || fileStorage.url + }] + : []; + return ( <> { if (!open) onClose(); }}> - + - Move - - {file.name} - +
    + + +
    + Move + + Pick a destination folder, and rename the file if you need to. + +
    +
    {/* Name */} @@ -259,60 +212,41 @@ export default function MoveDialog({
    - + {currentLocationUrl - ? availableFolders.find((f) => folderUrlsEqual(f.url, currentLocationUrl))?.name || - (() => { - try { - const url = new URL(currentLocationUrl); - return url.pathname.split("/").filter(Boolean).pop() || currentLocationUrl; - } catch { - return currentLocationUrl; - } - })() + ? folderPathLabel(currentLocationUrl) : "My Storages"}
    - {/* Available Folders */} + {/* Destination */}
    - {isLoadingFolders ? ( -
    - -

    Loading folders...

    -
    - ) : filteredFolders.length === 0 ? ( -
    - No folders available to move to -
    - ) : ( -
    - {filteredFolders.map((folder) => ( - - ))} -
    - )} +
    + +
    - {filteredFolders.length > 0 && !selectedFolderUrl && ( -
    - - Select a location to show the folder path + {selectedFolderUrl && ( +
    +

    Moving to

    +

    + {folderPathLabel(selectedFolderUrl)} +

    )}
    diff --git a/app/components/file-manager/FileManagerContent.tsx b/app/components/file-manager/FileManagerContent.tsx index b10dd17..ad00cc4 100644 --- a/app/components/file-manager/FileManagerContent.tsx +++ b/app/components/file-manager/FileManagerContent.tsx @@ -75,7 +75,6 @@ export default function FileManagerContent() { displayFiles, isLoadingFiles, browseError, - availableFolders, getCurrentLocationUrl, refresh, triggerDelayedRefresh, @@ -505,7 +504,7 @@ export default function FileManagerContent() { isOpen={isDialog(activeDialog, "move")} onClose={closeDialog} file={isDialog(activeDialog, "move") ? activeDialog.file : null} - availableFolders={availableFolders} + storages={storages} currentLocationUrl={getCurrentLocationUrl()} onMoved={(destinationUrl) => { invalidateContainers([destinationUrl]); diff --git a/app/components/file-manager/FileManagerProvider.tsx b/app/components/file-manager/FileManagerProvider.tsx index c09e736..12ac036 100644 --- a/app/components/file-manager/FileManagerProvider.tsx +++ b/app/components/file-manager/FileManagerProvider.tsx @@ -77,7 +77,6 @@ export default function FileManagerProvider({ displayFiles: browse.displayFiles, isLoadingFiles: browse.isLoadingFiles, browseError: browse.browseError, - availableFolders: browse.availableFolders, getCurrentLocationUrl: browse.getCurrentLocationUrl, refresh: browse.refresh, triggerDelayedRefresh: browse.triggerDelayedRefresh, diff --git a/app/components/file-manager/context/fileManagerContext.ts b/app/components/file-manager/context/fileManagerContext.ts index 3e1632b..9d3a6b6 100644 --- a/app/components/file-manager/context/fileManagerContext.ts +++ b/app/components/file-manager/context/fileManagerContext.ts @@ -28,7 +28,6 @@ export interface FileManagerBrowseContextValue { displayFiles: FileItemData[]; isLoadingFiles: boolean; browseError: Error | null; - availableFolders: FileItemData[]; getCurrentLocationUrl: () => string; refresh: () => void; triggerDelayedRefresh: () => void; diff --git a/app/components/file-manager/hooks/useContainerBrowse.ts b/app/components/file-manager/hooks/useContainerBrowse.ts index a7fd50b..2b1a252 100644 --- a/app/components/file-manager/hooks/useContainerBrowse.ts +++ b/app/components/file-manager/hooks/useContainerBrowse.ts @@ -18,7 +18,6 @@ export interface UseContainerBrowseResult { displayFiles: FileItemData[]; isLoadingFiles: boolean; browseError: Error | null; - availableFolders: FileItemData[]; getCurrentLocationUrl: () => string; refresh: () => void; triggerDelayedRefresh: () => void; @@ -113,17 +112,6 @@ export function useContainerBrowse({ // Root: show storages. Inside a storage, show browsed container contents. const displayFiles = selectedStorageId ? browsedFiles : storageFiles; - // Move dialog targets: all storages + folders in the current listing. - const availableFolders: FileItemData[] = useMemo( - () => [ - ...storageFiles, - ...(selectedStorageId - ? browsedFiles.filter((f) => f.type === "folder") - : []), - ], - [browsedFiles, selectedStorageId, storageFiles], - ); - /** Absolute URL of the folder the user is currently in (for move dialog, etc.). */ const getCurrentLocationUrl = useCallback((): string => { if (!selectedStorageId) { @@ -141,7 +129,6 @@ export function useContainerBrowse({ displayFiles, isLoadingFiles, browseError, - availableFolders, getCurrentLocationUrl, refresh, triggerDelayedRefresh, diff --git a/app/globals.css b/app/globals.css index 222d2fe..f7406b7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -93,6 +93,11 @@ --radius-4xl: calc(var(--radius) * 2.6); } +@utility scrollbar-themed { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--primary) 45%, transparent) transparent; +} + body { font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; margin: 0; @@ -139,11 +144,13 @@ body { @layer base { * { - @apply border-border outline-ring/50; + @apply border-border outline-ring/50 scrollbar-themed; } + body { @apply bg-background text-foreground; } + html { @apply font-sans; } diff --git a/app/lib/hooks/index.ts b/app/lib/hooks/index.ts index 0ec4bdb..e89e833 100644 --- a/app/lib/hooks/index.ts +++ b/app/lib/hooks/index.ts @@ -14,4 +14,5 @@ export type { SolidStorage } from "./useSolidStorages"; export { useUserProfile } from "./useUserProfile"; export type { UserProfile } from "./useUserProfile"; export { useBrowseStorage } from "./useBrowseStorage"; - +export { useFolderTree } from "./useFolderTree"; +export type { UseFolderTreeResult } from "./useFolderTree"; diff --git a/app/lib/hooks/useFolderTree.ts b/app/lib/hooks/useFolderTree.ts new file mode 100644 index 0000000..aa8a134 --- /dev/null +++ b/app/lib/hooks/useFolderTree.ts @@ -0,0 +1,168 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from "react"; +import { + FolderTreeChild, + ensureTrailingSlash, + getAuthenticatedSession, + fetchContainerListing, + foldersFromListing, +} from "../helpers"; +import { + getContainerListing, + loadContainerListing, + subscribeContainerCache, + getContainerCacheVersion, +} from "../cache"; + + +export interface UseFolderTreeResult { + expandedUrls: Set; + loadingUrls: Set; + errorByUrl: Record; + /** Child folders per expanded folder URL. */ + childrenByUrl: Record; + toggleExpand: (folderUrl: string) => Promise; + expandPath: (folderUrls: string[]) => Promise; +} + +/** + * Expand/collapse state for a lazily loaded folder tree. + * + * Children are never kept locally: they are read back out of the shared container cache. + */ + +export function useFolderTree(): UseFolderTreeResult { + const [expandedUrls, setExpandedUrls] = useState>(new Set()); + const [loadingUrls, setLoadingUrls] = useState>(new Set()); + const [errorByUrl, setErrorByUrl] = useState>({}); + + const cacheVersion = useSyncExternalStore( + subscribeContainerCache, + getContainerCacheVersion, + getContainerCacheVersion + ) + + // get the children from the shared cache + const childrenByUrl = useMemo(() => { + void cacheVersion; // force recompute when the cache changes + const next: Record = {}; + for (const url of expandedUrls) { + const cached = getContainerListing(url); + if (cached) { + next[url] = foldersFromListing(cached); + } + } + return next; + }, [expandedUrls, cacheVersion]); + + const expandedUrlsRef = useRef(expandedUrls); + expandedUrlsRef.current = expandedUrls; + + // Load child folders via the shared container cache + const loadChildren = useCallback(async (folderUrl: string) => { + const normalizedUrl = ensureTrailingSlash(folderUrl); + + if (getContainerListing(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(); + await loadContainerListing(normalizedUrl, + () => fetchContainerListing(normalizedUrl, fetch)); + } 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; + }); + } + }, []); + + // After cache invalidation, refetch any expanded folder that no longer has a listing. + useEffect(() => { + return subscribeContainerCache(() => { + for (const url of expandedUrlsRef.current) { + if (!getContainerListing(url)) { + void loadChildren(url); + } + } + }) + }, [loadChildren]); + + // 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]); + + // Open several branches at once. Children load in parallel because every + // folder in the chain has to be opened for the deepest one to show. + const expandPath = useCallback(async (folderUrls: string[]) => { + const normalized = folderUrls.map(ensureTrailingSlash); + if (normalized.length === 0) { + return; + } + + setExpandedUrls((prev) => { + const next = new Set(prev); + for (const url of normalized) { + next.add(url); + } + return next; + }); + + await Promise.all(normalized.map((url) => loadChildren(url))); + }, [loadChildren]); + + return { + expandedUrls, + loadingUrls, + errorByUrl, + childrenByUrl, + toggleExpand, + expandPath, + }; +}