Skip to content

Commit 11bb364

Browse files
Merge pull request #114 from solid-contrib/fix-move-folder-tree
Make the move modal display the folders in hierachy and use the cache
2 parents c195c14 + 6b8ed62 commit 11bb364

10 files changed

Lines changed: 460 additions & 261 deletions

File tree

‎app/components/FolderTree.tsx‎

Lines changed: 7 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"use client";
22

3-
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3+
import { useMemo} from "react";
44
import { ChevronRight, ChevronDown, Folder } from "lucide-react";
55
import { SolidStorage } from "../lib/hooks/useSolidStorages";
6-
import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash, getAuthenticatedSession, fetchContainerListing, foldersFromListing } from "../lib/helpers";
7-
import { getContainerListing, loadContainerListing, subscribeContainerCache, getContainerCacheVersion } from "../lib/cache";
6+
import { FolderTreeChild, folderUrlsEqual, ensureTrailingSlash } from "../lib/helpers";
87
import {
98
SidebarMenu,
109
SidebarMenuButton,
@@ -13,6 +12,7 @@ import {
1312
SidebarMenuSubItem,
1413
SidebarMenuSkeleton,
1514
} from "@/components/ui/sidebar";
15+
import { useFolderTree } from "../lib/hooks/useFolderTree";
1616

1717
interface FolderTreeProps {
1818
storages: SolidStorage[];
@@ -25,116 +25,16 @@ export default function FolderTree({
2525
currentFolderUrl,
2626
onNavigate
2727
}: FolderTreeProps) {
28-
const [expandedUrls, setExpandedUrls] = useState<Set<string>>(new Set());
29-
const [loadingUrls, setLoadingUrls] = useState<Set<string>>(new Set());
30-
const [errorByUrl, setErrorByUrl] = useState<Record<string, string>>({});
31-
32-
const cacheVersion = useSyncExternalStore(
33-
subscribeContainerCache,
34-
getContainerCacheVersion,
35-
getContainerCacheVersion,
36-
)
37-
38-
// Children come from the shared cache, not a local copy.
39-
// cacheVersion makes this recompute when listings are written or invalidated.
40-
const childrenByUrl = useMemo(() => {
41-
// cacheVersion is read so listings recompute after cache writes/invalidation.
42-
void cacheVersion;
43-
const next: Record<string, FolderTreeChild[]> = {};
44-
for (const url of expandedUrls) {
45-
const cached = getContainerListing(url);
46-
if (cached) {
47-
next[url] = foldersFromListing(cached);
48-
}
49-
}
50-
return next;
51-
}, [cacheVersion, expandedUrls])
52-
53-
const expandedUrlsRef = useRef(expandedUrls);
54-
expandedUrlsRef.current = expandedUrls;
28+
const { expandedUrls, loadingUrls, errorByUrl, childrenByUrl, toggleExpand } =
29+
useFolderTree();
5530

5631
// Keep the current folder URL in one shape so highlight checks stay reliable.
5732
const normalizedCurrentFolderUrl = useMemo(() => (
5833
currentFolderUrl ? ensureTrailingSlash(currentFolderUrl) : null
5934
), [currentFolderUrl]);
6035

61-
// Load child folders via the shared container cache (same data as main browse).
62-
const loadChildren = useCallback(async (folderUrl: string) => {
63-
const normalizedUrl = ensureTrailingSlash(folderUrl);
64-
65-
if (getContainerListing(normalizedUrl)) {
66-
return;
67-
}
68-
69-
setLoadingUrls((prev) => {
70-
const next = new Set(prev);
71-
next.add(normalizedUrl);
72-
return next;
73-
});
74-
75-
setErrorByUrl((prev) => {
76-
if (!(normalizedUrl in prev)) return prev;
77-
const next = { ...prev };
78-
delete next[normalizedUrl];
79-
return next;
80-
});
81-
82-
try {
83-
const { fetch } = getAuthenticatedSession();
84-
await loadContainerListing(
85-
normalizedUrl,
86-
() => fetchContainerListing(normalizedUrl, fetch),
87-
);
88-
} catch (error) {
89-
const message =
90-
error instanceof Error ? error.message : "Failed to load folders";
91-
setErrorByUrl((prev) => ({ ...prev, [normalizedUrl]: message }));
92-
} finally {
93-
setLoadingUrls((prev) => {
94-
const next = new Set(prev);
95-
next.delete(normalizedUrl);
96-
return next;
97-
});
98-
}
99-
}, []);
100-
101-
102-
// After cache invalidation, refetch any expanded folder that no longer has a listing.
103-
useEffect(() => {
104-
return subscribeContainerCache(() => {
105-
for (const url of expandedUrlsRef.current) {
106-
if (!getContainerListing(url)) {
107-
void loadChildren(url)
108-
}
109-
}
110-
})
111-
}, [loadChildren])
112-
113-
// Open or close a folder branch. Fetch children only when opening.
114-
const toggleExpand = useCallback(async (folderUrl: string) => {
115-
const normalizedUrl = ensureTrailingSlash(folderUrl);
116-
117-
const isExpanded = expandedUrls.has(normalizedUrl);
118-
if (isExpanded) {
119-
setExpandedUrls((prev) => {
120-
const next = new Set(prev);
121-
next.delete(normalizedUrl);
122-
return next;
123-
});
124-
return;
125-
}
126-
127-
setExpandedUrls((prev) => {
128-
const next = new Set(prev);
129-
next.add(normalizedUrl);
130-
return next;
131-
});
132-
133-
await loadChildren(normalizedUrl);
134-
}, [expandedUrls, loadChildren]);
135-
13636
// Render one folder row and its nested children when expanded.
137-
const renderNode = useCallback((node: FolderTreeChild) => {
37+
function renderNode(node: FolderTreeChild) {
13838
const nodeUrl = ensureTrailingSlash(node.url);
13939
const isExpanded = expandedUrls.has(nodeUrl);
14040
const isLoading = loadingUrls.has(nodeUrl);
@@ -194,16 +94,7 @@ export default function FolderTree({
19494
)}
19595
</SidebarMenuItem>
19696
);
197-
}, [
198-
childrenByUrl,
199-
errorByUrl,
200-
expandedUrls,
201-
loadingUrls,
202-
normalizedCurrentFolderUrl,
203-
onNavigate,
204-
toggleExpand,
205-
],
206-
);
97+
}
20798

20899
// Turn storage roots into the same shape used by child folder nodes.
209100
const rootNodes: FolderTreeChild[] = useMemo(
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { ChevronDown, ChevronRight, Folder } from "lucide-react";
5+
import { Skeleton } from "@/components/ui/skeleton";
6+
import { cn } from "@/lib/utils";
7+
import {
8+
FolderTreeChild,
9+
ensureTrailingSlash,
10+
folderUrlsEqual,
11+
getAncestorFolderUrls
12+
} from "../lib/helpers";
13+
import { useFolderTree, type UseFolderTreeResult } from "../lib/hooks";
14+
15+
interface FolderTreePickerProps {
16+
roots: FolderTreeChild[];
17+
selectedUrl: string | null;
18+
onSelect: (folderUrl: string) => void;
19+
/**
20+
* A folder that may be browsed but cannot be chosen, e.g the one the resource is
21+
* already in.
22+
*/
23+
currentUrl?: string | null;
24+
}
25+
26+
export default function FolderTreePicker({
27+
roots,
28+
selectedUrl,
29+
onSelect,
30+
currentUrl,
31+
}: FolderTreePickerProps) {
32+
const tree = useFolderTree();
33+
const { expandPath } = tree;
34+
35+
// Expand the path to the current folder so the user can see where they are.
36+
const revealUrls = [
37+
...new Set([
38+
...roots.map((root) => ensureTrailingSlash(root.url)),
39+
...(currentUrl ? roots.flatMap((root) =>
40+
getAncestorFolderUrls(currentUrl, root.url)) : []),
41+
]),
42+
];
43+
44+
// make sure the path to the current folder is expanded once when the component mounts
45+
const revealKey = revealUrls.join("|");
46+
47+
useEffect(() => {
48+
if (!revealKey) {
49+
return;
50+
}
51+
void expandPath(revealKey.split("|"));
52+
}, [revealKey, expandPath]);
53+
54+
if (roots.length === 0) {
55+
return (
56+
<p className="px-4 py-3 text-sm text-muted-foreground">
57+
No folders available
58+
</p>
59+
);
60+
}
61+
62+
return (
63+
<ul role="tree" aria-label="Destination folders" className="py-1">
64+
{roots.map((root) => (
65+
<FolderTreeNode
66+
key={ensureTrailingSlash(root.url)}
67+
node={root}
68+
depth={0}
69+
tree={tree}
70+
selectedUrl={selectedUrl}
71+
currentUrl={currentUrl}
72+
onSelect={onSelect}
73+
/>
74+
))}
75+
</ul>
76+
);
77+
}
78+
79+
interface FolderTreeNodeProps {
80+
node: FolderTreeChild;
81+
depth: number;
82+
/** Shared expand/load state, passed whole so the recursion stays readable. */
83+
tree: UseFolderTreeResult;
84+
selectedUrl: string | null;
85+
currentUrl?: string | null;
86+
onSelect: (folderUrl: string) => void;
87+
}
88+
89+
/** One folder row, plus its children when the branch is open. */
90+
function FolderTreeNode({
91+
node,
92+
depth,
93+
tree,
94+
selectedUrl,
95+
currentUrl,
96+
onSelect,
97+
}: FolderTreeNodeProps) {
98+
const nodeUrl = ensureTrailingSlash(node.url);
99+
const isExpanded = tree.expandedUrls.has(nodeUrl);
100+
const isLoading = tree.loadingUrls.has(nodeUrl);
101+
const hasError = Boolean(tree.errorByUrl[nodeUrl]);
102+
const children = tree.childrenByUrl[nodeUrl];
103+
const isSelected =
104+
selectedUrl !== null && folderUrlsEqual(selectedUrl, nodeUrl);
105+
const isCurrent =
106+
currentUrl != null && folderUrlsEqual(currentUrl, nodeUrl);
107+
108+
const rowRef = useRef<HTMLDivElement>(null);
109+
110+
// Scroll the current folder into view.
111+
useEffect(() => {
112+
if (isCurrent) {
113+
rowRef.current?.scrollIntoView({ block: "nearest" });
114+
}
115+
}, [isCurrent]);
116+
117+
const childIndent = `${(depth + 1) * 1.25 + 0.5}rem`;
118+
119+
return (
120+
<li
121+
role="treeitem"
122+
aria-expanded={isExpanded}
123+
aria-selected={isSelected}
124+
aria-disabled={isCurrent || undefined}
125+
>
126+
<div
127+
ref={rowRef}
128+
className={cn(
129+
"flex items-center gap-1 border-l-4 pr-2 transition-colors",
130+
isSelected
131+
? "border-primary bg-primary/20"
132+
: "border-transparent hover:bg-primary/10",
133+
)}
134+
style={{ paddingLeft: `${depth * 1.25}rem` }}
135+
>
136+
<button
137+
type="button"
138+
onClick={() => void tree.toggleExpand(nodeUrl)}
139+
className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-primary/20"
140+
aria-label={
141+
isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
142+
}
143+
>
144+
{isExpanded ? (
145+
<ChevronDown className="h-4 w-4" aria-hidden="true" />
146+
) : (
147+
<ChevronRight className="h-4 w-4" aria-hidden="true" />
148+
)}
149+
</button>
150+
151+
<button
152+
type="button"
153+
onClick={() => onSelect(nodeUrl)}
154+
disabled={isCurrent}
155+
title={node.name}
156+
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 py-2.5 text-left disabled:cursor-not-allowed disabled:opacity-50"
157+
>
158+
<Folder
159+
className="h-5 w-5 shrink-0 text-muted-foreground"
160+
aria-hidden="true"
161+
/>
162+
<span className="truncate text-sm text-foreground">{node.name}</span>
163+
{isCurrent && (
164+
<span className="shrink-0 text-xs text-muted-foreground">
165+
(current)
166+
</span>
167+
)}
168+
</button>
169+
</div>
170+
171+
{isExpanded && (
172+
<ul role="group">
173+
{isLoading && (
174+
<li className="py-2 pr-2" style={{ paddingLeft: childIndent }}>
175+
<Skeleton className="h-5 w-40" />
176+
</li>
177+
)}
178+
179+
{!isLoading && hasError && (
180+
<li
181+
className="py-2 pr-2 text-xs text-destructive"
182+
style={{ paddingLeft: childIndent }}
183+
>
184+
Failed to load folders
185+
</li>
186+
)}
187+
188+
{!isLoading && !hasError && children?.length === 0 && (
189+
<li
190+
className="py-2 pr-2 text-xs text-muted-foreground"
191+
style={{ paddingLeft: childIndent }}
192+
>
193+
No subfolders
194+
</li>
195+
)}
196+
197+
{!isLoading &&
198+
!hasError &&
199+
children?.map((child) => (
200+
<FolderTreeNode
201+
key={ensureTrailingSlash(child.url)}
202+
node={child}
203+
depth={depth + 1}
204+
tree={tree}
205+
selectedUrl={selectedUrl}
206+
currentUrl={currentUrl}
207+
onSelect={onSelect}
208+
/>
209+
))}
210+
</ul>
211+
)}
212+
</li>
213+
);
214+
}

0 commit comments

Comments
 (0)