From e4bf341c75aacabddf5d007ae9a31d6eb9c0c881 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Mon, 26 Oct 2026 16:33:56 +0000 Subject: [PATCH 01/10] Extract folder tree state into a hook --- app/components/FolderTree.tsx | 123 ++------------------------- app/lib/hooks/index.ts | 3 +- app/lib/hooks/useFolderTree.ts | 147 +++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 117 deletions(-) create mode 100644 app/lib/hooks/useFolderTree.ts 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/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..d9ffc23 --- /dev/null +++ b/app/lib/hooks/useFolderTree.ts @@ -0,0 +1,147 @@ +"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; +} + +/** + * 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]); + + return { + expandedUrls, + loadingUrls, + errorByUrl, + childrenByUrl, + toggleExpand, + }; +} From 1916db4329d13fe31bb5dc2e59f016c3df8b8007 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 10:12:52 +0000 Subject: [PATCH 02/10] Add a folder tree picker component --- app/components/FolderTreePicker.tsx | 177 ++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 app/components/FolderTreePicker.tsx diff --git a/app/components/FolderTreePicker.tsx b/app/components/FolderTreePicker.tsx new file mode 100644 index 0000000..da157b3 --- /dev/null +++ b/app/components/FolderTreePicker.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { ChevronDown, ChevronRight, Folder } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { + FolderTreeChild, + ensureTrailingSlash, + folderUrlsEqual, +} 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. + */ + disabledUrl?: string | null; +} + +export default function FolderTreePicker({ + roots, + selectedUrl, + onSelect, + disabledUrl, +}: FolderTreePickerProps) { + const tree = useFolderTree(); + + 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; + disabledUrl?: string | null; + onSelect: (folderUrl: string) => void; + } + + /** One folder row, plus its children when the branch is open. */ + function FolderTreeNode({ + node, + depth, + tree, + selectedUrl, + disabledUrl, + 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 isDisabled = + disabledUrl != null && folderUrlsEqual(disabledUrl, nodeUrl); + + 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) => ( + + ))} +
    + )} +
  • + ); + } +} From fc55a2879d3b054281295e4d30bdbed3fc371a20 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 11:43:09 +0000 Subject: [PATCH 03/10] Show move destinations as a folder tree --- app/components/MoveDialog.tsx | 158 +++++++++------------------------- 1 file changed, 41 insertions(+), 117 deletions(-) diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index fa6ce12..2381a6b 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -22,14 +22,15 @@ import { getParentContainerUrl, ensureTrailingSlash, folderUrlsEqual, - fetchContainerListing, describeMoveError, describeMoveResult, + extractNameFromUrl, + type FolderTreeChild, type MoveResult, } from "../lib/helpers"; import { useNameConflictPrompt } from "./useNameConflictPrompt"; -import { loadContainerListing } from "../lib/cache"; -import { Folder, AlertTriangle } from "lucide-react"; +import { Folder } from "lucide-react"; +import FolderTreePicker from "./FolderTreePicker"; interface MoveDialogProps { isOpen: boolean; @@ -40,6 +41,14 @@ interface MoveDialogProps { 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, @@ -50,8 +59,6 @@ export default function MoveDialog({ }: 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 +71,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,13 +147,15 @@ 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 = availableFolders.find( + (folder) => folder.type === "folder" && file.url.startsWith(folder.url), ); + const roots: FolderTreeChild[] = fileStorage + ? [{ url: ensureTrailingSlash(fileStorage.url), name: fileStorage.name }] + : []; + return ( <> { if (!open) onClose(); }}> @@ -261,58 +205,38 @@ 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; - } - })() + ? availableFolders.find((f) => + folderUrlsEqual(f.url, currentLocationUrl), + )?.name || extractNameFromUrl(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)} +

    )} From d4833067bba2c2c28d961719cf682144464289f7 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 12:07:09 +0000 Subject: [PATCH 04/10] Widen the move dialog and show more of the tree --- app/components/MoveDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index 2381a6b..15244ed 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -159,7 +159,7 @@ export default function MoveDialog({ return ( <> { if (!open) onClose(); }}> - + Move @@ -218,7 +218,7 @@ export default function MoveDialog({ -
    +
    Date: Tue, 27 Oct 2026 14:05:04 +0000 Subject: [PATCH 05/10] Open the move tree at the file's current location --- app/components/FolderTreePicker.tsx | 59 +++++++++++++++++++++++------ app/components/MoveDialog.tsx | 13 +++---- app/lib/hooks/useFolderTree.ts | 21 ++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/app/components/FolderTreePicker.tsx b/app/components/FolderTreePicker.tsx index da157b3..0645c9a 100644 --- a/app/components/FolderTreePicker.tsx +++ b/app/components/FolderTreePicker.tsx @@ -1,5 +1,6 @@ "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"; @@ -7,6 +8,7 @@ import { FolderTreeChild, ensureTrailingSlash, folderUrlsEqual, + getAncestorFolderUrls } from "../lib/helpers"; import { useFolderTree, type UseFolderTreeResult } from "../lib/hooks"; @@ -18,16 +20,36 @@ interface FolderTreePickerProps { * A folder that may be browsed but cannot be chosen, e.g the one the resource is * already in. */ - disabledUrl?: string | null; + currentUrl?: string | null; } export default function FolderTreePicker({ roots, selectedUrl, onSelect, - disabledUrl, + 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 ( @@ -46,7 +68,7 @@ export default function FolderTreePicker({ depth={0} tree={tree} selectedUrl={selectedUrl} - disabledUrl={disabledUrl} + currentUrl={currentUrl} onSelect={onSelect} /> ))} @@ -59,7 +81,7 @@ export default function FolderTreePicker({ /** Shared expand/load state, passed whole so the recursion stays readable. */ tree: UseFolderTreeResult; selectedUrl: string | null; - disabledUrl?: string | null; + currentUrl?: string | null; onSelect: (folderUrl: string) => void; } @@ -69,7 +91,7 @@ export default function FolderTreePicker({ depth, tree, selectedUrl, - disabledUrl, + currentUrl, onSelect, }: FolderTreeNodeProps) { const nodeUrl = ensureTrailingSlash(node.url); @@ -79,8 +101,17 @@ export default function FolderTreePicker({ const children = tree.childrenByUrl[nodeUrl]; const isSelected = selectedUrl !== null && folderUrlsEqual(selectedUrl, nodeUrl); - const isDisabled = - disabledUrl != null && folderUrlsEqual(disabledUrl, 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`; @@ -89,9 +120,10 @@ export default function FolderTreePicker({ role="treeitem" aria-expanded={isExpanded} aria-selected={isSelected} - aria-disabled={isDisabled || undefined} + aria-disabled={isCurrent || undefined} >
    onSelect(nodeUrl)} - disabled={isDisabled} - title={isDisabled ? `${node.name} (already here)` : node.name} + disabled={isCurrent} + title={node.name} className="flex min-w-0 flex-1 items-center gap-3 py-2.5 text-left disabled:cursor-not-allowed disabled:opacity-50" >
    @@ -165,7 +202,7 @@ export default function FolderTreePicker({ depth={depth + 1} tree={tree} selectedUrl={selectedUrl} - disabledUrl={disabledUrl} + currentUrl={currentUrl} onSelect={onSelect} /> ))} diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index 15244ed..dcbc12a 100644 --- a/app/components/MoveDialog.tsx +++ b/app/components/MoveDialog.tsx @@ -21,10 +21,8 @@ import { getAuthenticatedSession, getParentContainerUrl, ensureTrailingSlash, - folderUrlsEqual, describeMoveError, describeMoveResult, - extractNameFromUrl, type FolderTreeChild, type MoveResult, } from "../lib/helpers"; @@ -203,11 +201,12 @@ export default function MoveDialog({
    - + {currentLocationUrl - ? availableFolders.find((f) => - folderUrlsEqual(f.url, currentLocationUrl), - )?.name || extractNameFromUrl(currentLocationUrl) + ? folderPathLabel(currentLocationUrl) : "My Storages"}
    @@ -223,7 +222,7 @@ export default function MoveDialog({ roots={roots} selectedUrl={selectedFolderUrl} onSelect={setSelectedFolderUrl} - disabledUrl={currentLocationUrl} + currentUrl={currentLocationUrl} />
    diff --git a/app/lib/hooks/useFolderTree.ts b/app/lib/hooks/useFolderTree.ts index d9ffc23..aa8a134 100644 --- a/app/lib/hooks/useFolderTree.ts +++ b/app/lib/hooks/useFolderTree.ts @@ -30,6 +30,7 @@ export interface UseFolderTreeResult { /** Child folders per expanded folder URL. */ childrenByUrl: Record; toggleExpand: (folderUrl: string) => Promise; + expandPath: (folderUrls: string[]) => Promise; } /** @@ -137,11 +138,31 @@ export function useFolderTree(): UseFolderTreeResult { 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, }; } From f7827af84be32ff0f958c8d035c793e60d10d06e Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 15:18:54 +0000 Subject: [PATCH 06/10] Pass storages to the move dialog instead of a mixed folder list --- app/components/MoveDialog.tsx | 14 +++++++++----- app/components/file-manager/FileManagerContent.tsx | 3 +-- .../file-manager/FileManagerProvider.tsx | 1 - .../file-manager/context/fileManagerContext.ts | 1 - .../file-manager/hooks/useContainerBrowse.ts | 13 ------------- 5 files changed, 10 insertions(+), 22 deletions(-) diff --git a/app/components/MoveDialog.tsx b/app/components/MoveDialog.tsx index dcbc12a..3451f7a 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, @@ -34,7 +35,7 @@ interface MoveDialogProps { isOpen: boolean; onClose: () => void; file: FileItemData | null; - availableFolders: FileItemData[]; + storages: SolidStorage[]; currentLocationUrl: string; onMoved?: (destinationUrl: string) => void; } @@ -51,7 +52,7 @@ export default function MoveDialog({ isOpen, onClose, file, - availableFolders, + storages, currentLocationUrl, onMoved, }: MoveDialogProps) { @@ -146,12 +147,15 @@ export default function MoveDialog({ if (!file) return null; // Move is scoped to the storage the file already lives in. - const fileStorage = availableFolders.find( - (folder) => folder.type === "folder" && file.url.startsWith(folder.url), + const fileStorage = storages.find( + (storage) => file.url.startsWith(ensureTrailingSlash(storage.url)), ); const roots: FolderTreeChild[] = fileStorage - ? [{ url: ensureTrailingSlash(fileStorage.url), name: fileStorage.name }] + ? [{ + url: ensureTrailingSlash(fileStorage.url), + name: fileStorage.name || fileStorage.url + }] : []; return ( 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, From e53425b1331f54f3de1b659d6322633cfb4eed43 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 16:04:36 +0000 Subject: [PATCH 07/10] Keep the folder tree from remounting on every render --- app/components/FolderTreePicker.tsx | 262 ++++++++++++++-------------- 1 file changed, 131 insertions(+), 131 deletions(-) diff --git a/app/components/FolderTreePicker.tsx b/app/components/FolderTreePicker.tsx index 0645c9a..253494c 100644 --- a/app/components/FolderTreePicker.tsx +++ b/app/components/FolderTreePicker.tsx @@ -74,141 +74,141 @@ export default function FolderTreePicker({ ))} ); +} - 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; - } +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]); - /** 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`; + const childIndent = `${(depth + 1) * 1.25 + 0.5}rem`; - return ( -
  • +
    -
    void tree.toggleExpand(nodeUrl)} + className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted" + aria-label={ + isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}` + } + > + {isExpanded ? ( +
    - - {isExpanded && ( -
      - {isLoading && ( -
    • - -
    • - )} - - {!isLoading && hasError && ( -
    • - Failed to load folders -
    • - )} - - {!isLoading && !hasError && children?.length === 0 && ( -
    • - No subfolders -
    • - )} - - {!isLoading && - !hasError && - children?.map((child) => ( - - ))} -
    - )} -
  • - ); - } +
    + + {isExpanded && ( +
      + {isLoading && ( +
    • + +
    • + )} + + {!isLoading && hasError && ( +
    • + Failed to load folders +
    • + )} + + {!isLoading && !hasError && children?.length === 0 && ( +
    • + No subfolders +
    • + )} + + {!isLoading && + !hasError && + children?.map((child) => ( + + ))} +
    + )} + + ); } From c5c58447f8bbc12e3804148189b906e5ce5d1813 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 27 Oct 2026 16:09:18 +0000 Subject: [PATCH 08/10] Use theme purple for folder tree hover and selection --- app/components/FolderTreePicker.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/FolderTreePicker.tsx b/app/components/FolderTreePicker.tsx index 253494c..a88fce0 100644 --- a/app/components/FolderTreePicker.tsx +++ b/app/components/FolderTreePicker.tsx @@ -128,15 +128,15 @@ function FolderTreeNode({ className={cn( "flex items-center gap-1 border-l-4 pr-2 transition-colors", isSelected - ? "border-primary bg-accent" - : "border-transparent hover:bg-muted", + ? "border-primary bg-primary/20" + : "border-transparent hover:bg-primary/10", )} style={{ paddingLeft: `${depth * 1.25}rem` }} >