Skip to content

Commit 888bf36

Browse files
feature: make a copy of folder/file resource
1 parent e1e822f commit 888bf36

5 files changed

Lines changed: 307 additions & 73 deletions

File tree

app/components/FileItem.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface FileItemProps {
2323
onDoubleClick: (file: FileItemData) => void;
2424
onRename?: (file: FileItemData) => void;
2525
onPreview?: (file: FileItemData) => void;
26+
onCopy?: (file: FileItemData) => void;
2627
isSelected?: boolean;
2728
}
2829

@@ -33,6 +34,7 @@ export default function FileItem({
3334
onDoubleClick,
3435
onRename,
3536
onPreview,
37+
onCopy,
3638
isSelected = false,
3739
}: FileItemProps) {
3840
const [isHovered, setIsHovered] = useState(false);
@@ -128,7 +130,7 @@ export default function FileItem({
128130
onRename={onRename}
129131
onPreview={onPreview}
130132
onDownload={(f) => console.log("Download:", f.name)}
131-
onCopy={(f) => console.log("Copy:", f.name)}
133+
onCopy={onCopy}
132134
onMove={(f) => console.log("Move:", f.name)}
133135
onDelete={(f) => console.log("Delete:", f.name)}
134136
/>
@@ -176,7 +178,7 @@ export default function FileItem({
176178
onRename={onRename}
177179
onPreview={onPreview}
178180
onDownload={(f) => console.log("Download:", f.name)}
179-
onCopy={(f) => console.log("Copy:", f.name)}
181+
onCopy={onCopy}
180182
onMove={(f) => console.log("Move:", f.name)}
181183
onDelete={(f) => console.log("Delete:", f.name)}
182184
/>

app/components/FileList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface FileListProps {
1212
onFileDoubleClick: (file: FileItemData) => void;
1313
onFileRename?: (file: FileItemData) => void;
1414
onFilePreview?: (file: FileItemData) => void;
15+
onFileCopy?: (file: FileItemData) => void;
1516
selectedFileIds: string[];
1617
}
1718

@@ -24,6 +25,7 @@ export default function FileList({
2425
onFileDoubleClick,
2526
onFileRename,
2627
onFilePreview,
28+
onFileCopy,
2729
selectedFileIds,
2830
}: FileListProps) {
2931
const [view, setView] = useState<"grid" | "list">(() => {
@@ -59,6 +61,7 @@ export default function FileList({
5961
onDoubleClick={onFileDoubleClick}
6062
onRename={onFileRename}
6163
onPreview={onFilePreview}
64+
onCopy={onFileCopy}
6265
isSelected={selectedFileIds.includes(file.id)}
6366
/>
6467
))}
@@ -74,6 +77,7 @@ export default function FileList({
7477
onDoubleClick={onFileDoubleClick}
7578
onRename={onFileRename}
7679
onPreview={onFilePreview}
80+
onCopy={onFileCopy}
7781
isSelected={selectedFileIds.includes(file.id)}
7882
/>
7983
))}

app/components/FileManager.tsx

Lines changed: 233 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
"use client";
22

33
import { useState, useEffect } from "react";
4+
import toast from "react-hot-toast";
5+
import {
6+
getFile,
7+
overwriteFile,
8+
createContainerAt,
9+
getSolidDataset,
10+
getContainedResourceUrlAll,
11+
UrlString,
12+
} from "@inrupt/solid-client";
413
import { useSearchParams, useRouter } from "next/navigation";
514
import AuthWrapper from "./AuthWrapper";
615
import Header from "./Header";
@@ -13,10 +22,178 @@ import RenameDialog from "./RenameDialog";
1322
import PreviewModal from "./PreviewModal";
1423
import FileUploadHandler from "./FileUploadHandler";
1524
import { FileItemData } from "./FileItem";
16-
import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
17-
import { filterProfileItems, buildBreadcrumbItems } from "../lib/helpers";
1825
import LoadingSpinner from "./shared/LoadingSpinner";
1926
import ErrorDisplay from "./shared/ErrorDisplay";
27+
import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
28+
import {
29+
buildBreadcrumbItems,
30+
getAuthenticatedSession,
31+
getDisplayNameFromMeta,
32+
updateMetaFile,
33+
} from "../lib/helpers";
34+
35+
const INVALID_NAME_CHARS = /[<>:"/\\|?*]/g;
36+
37+
const sanitizeResourceName = (name: string): string => {
38+
const sanitized = name.replace(INVALID_NAME_CHARS, "").trim();
39+
return sanitized || "Untitled";
40+
};
41+
42+
const decodeResourceNameFromUrl = (resourceUrl: string): string => {
43+
try {
44+
const urlObj = new URL(resourceUrl);
45+
const segments = urlObj.pathname.split("/").filter(Boolean);
46+
if (segments.length === 0) {
47+
return urlObj.hostname;
48+
}
49+
const lastSegment = resourceUrl.endsWith("/") ? segments[segments.length - 1] : segments[segments.length - 1];
50+
return decodeURIComponent(lastSegment);
51+
} catch {
52+
return resourceUrl;
53+
}
54+
};
55+
56+
const ensureTrailingSlash = (url: string): string => (url.endsWith("/") ? url : `${url}/`);
57+
58+
const getParentContainerUrl = (resourceUrl: string): string => {
59+
try {
60+
const urlObj = new URL(resourceUrl);
61+
const segments = urlObj.pathname.split("/").filter(Boolean);
62+
if (segments.length === 0) {
63+
return `${urlObj.origin}/`;
64+
}
65+
if (!resourceUrl.endsWith("/")) {
66+
segments.pop();
67+
} else if (segments.length > 0) {
68+
segments.pop();
69+
}
70+
const parentPath = segments.length ? `/${segments.join("/")}/` : "/";
71+
return `${urlObj.origin}${parentPath}`;
72+
} catch {
73+
return resourceUrl;
74+
}
75+
};
76+
77+
const shouldSkipResourceCopy = (resourceUrl: string): boolean => {
78+
return resourceUrl.endsWith(".meta") || resourceUrl.endsWith(".acl");
79+
};
80+
81+
const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
82+
try {
83+
const response = await fetchFn(url, { method: "HEAD" });
84+
if (response.status === 404) {
85+
return false;
86+
}
87+
if (response.status >= 200 && response.status < 300) {
88+
return true;
89+
}
90+
// For other statuses (401, 403, 405, etc.) assume the resource exists to avoid collisions
91+
return true;
92+
} catch {
93+
return false;
94+
}
95+
};
96+
97+
const generateCopyTarget = async (
98+
parentUrl: string,
99+
desiredName: string,
100+
isContainer: boolean,
101+
fetchFn: typeof fetch
102+
): Promise<{ targetUrl: string; displayName: string }> => {
103+
const parentWithSlash = ensureTrailingSlash(parentUrl);
104+
let attempt = 0;
105+
106+
while (attempt < 100) {
107+
const candidateDisplayName = attempt === 0 ? desiredName : `${desiredName} (${attempt})`;
108+
const candidatePathName = sanitizeResourceName(candidateDisplayName);
109+
const encodedName = encodeURIComponent(candidatePathName);
110+
const candidateUrl = isContainer ? `${parentWithSlash}${encodedName}/` : `${parentWithSlash}${encodedName}`;
111+
const exists = await resourceExists(candidateUrl, fetchFn);
112+
if (!exists) {
113+
return { targetUrl: candidateUrl, displayName: candidateDisplayName };
114+
}
115+
attempt += 1;
116+
}
117+
118+
throw new Error("Unable to generate a unique name for the copy");
119+
};
120+
121+
const copyFileFromSource = async (
122+
sourceUrl: string,
123+
targetUrl: string,
124+
displayName: string,
125+
fetchFn: typeof fetch,
126+
mimeTypeHint?: string
127+
): Promise<void> => {
128+
const fileBlob = await getFile(sourceUrl as UrlString, { fetch: fetchFn });
129+
const contentType = fileBlob.type || mimeTypeHint || "application/octet-stream";
130+
await overwriteFile(targetUrl as UrlString, fileBlob, {
131+
fetch: fetchFn,
132+
contentType,
133+
});
134+
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
135+
};
136+
137+
const copyFolderContents = async (
138+
sourceFolderUrl: string,
139+
destinationFolderUrl: string,
140+
fetchFn: typeof fetch
141+
): Promise<void> => {
142+
const dataset = await getSolidDataset(sourceFolderUrl, { fetch: fetchFn });
143+
const containedResources = getContainedResourceUrlAll(dataset);
144+
145+
for (const resourceUrl of containedResources) {
146+
if (shouldSkipResourceCopy(resourceUrl)) {
147+
continue;
148+
}
149+
150+
if (resourceUrl.endsWith("/")) {
151+
const childName = decodeResourceNameFromUrl(resourceUrl);
152+
const encodedChildName = encodeURIComponent(childName);
153+
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}/`;
154+
155+
await createContainerAt(childDestination as UrlString, { fetch: fetchFn });
156+
const childDisplayName =
157+
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
158+
await updateMetaFile(childDestination as UrlString, childDisplayName, fetchFn);
159+
160+
await copyFolderContents(resourceUrl, childDestination, fetchFn);
161+
} else {
162+
const childName = decodeResourceNameFromUrl(resourceUrl);
163+
const encodedChildName = encodeURIComponent(childName);
164+
const childDestination = `${ensureTrailingSlash(destinationFolderUrl)}${encodedChildName}`;
165+
const childDisplayName =
166+
(await getDisplayNameFromMeta(resourceUrl, fetchFn)) ?? childName;
167+
await copyFileFromSource(resourceUrl, childDestination, childDisplayName, fetchFn);
168+
}
169+
}
170+
};
171+
172+
const copyFileResource = async (file: FileItemData, fetchFn: typeof fetch): Promise<void> => {
173+
const originalLabel =
174+
(await getDisplayNameFromMeta(file.url, fetchFn)) ??
175+
file.name ??
176+
decodeResourceNameFromUrl(file.url);
177+
const parentUrl = getParentContainerUrl(file.url);
178+
const desiredName = `Copy of ${originalLabel}`;
179+
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, false, fetchFn);
180+
await copyFileFromSource(file.url, targetUrl, displayName, fetchFn, file.mimeType);
181+
};
182+
183+
const copyFolderResource = async (folder: FileItemData, fetchFn: typeof fetch): Promise<void> => {
184+
const originalLabel =
185+
(await getDisplayNameFromMeta(folder.url, fetchFn)) ??
186+
folder.name ??
187+
decodeResourceNameFromUrl(folder.url);
188+
const parentUrl = getParentContainerUrl(folder.url);
189+
const desiredName = `Copy of ${originalLabel}`;
190+
const { targetUrl, displayName } = await generateCopyTarget(parentUrl, desiredName, true, fetchFn);
191+
192+
await createContainerAt(targetUrl as UrlString, { fetch: fetchFn });
193+
await updateMetaFile(targetUrl as UrlString, displayName, fetchFn);
194+
await copyFolderContents(folder.url, targetUrl, fetchFn);
195+
};
196+
20197

21198
export default function FileManager() {
22199
const searchParams = useSearchParams();
@@ -26,6 +203,20 @@ export default function FileManager() {
26203
const [currentPath, setCurrentPath] = useState<string>("/");
27204
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
28205
const [isInitialized, setIsInitialized] = useState(false);
206+
const [refreshKey, setRefreshKey] = useState(0);
207+
208+
const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
209+
const [selectedFileForPermissions, setSelectedFileForPermissions] =
210+
useState<FileItemData | null>(null);
211+
const [permissions, setPermissions] = useState<Permission[]>([]);
212+
const [sidebarOpen, setSidebarOpen] = useState(false);
213+
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
214+
const [fileUploadTrigger, setFileUploadTrigger] = useState(0);
215+
const [showRenameDialog, setShowRenameDialog] = useState(false);
216+
const [fileToRename, setFileToRename] = useState<FileItemData | null>(null);
217+
const [showPreviewModal, setShowPreviewModal] = useState(false);
218+
const [fileToPreview, setFileToPreview] = useState<FileItemData | null>(null);
219+
29220
const [savedUrl, setSavedUrl] = useState<string | null>(() => {
30221
if (typeof window === "undefined") return null;
31222

@@ -180,19 +371,8 @@ export default function FileManager() {
180371
: currentPath
181372
: null;
182373

183-
const [refreshKey, setRefreshKey] = useState(0);
184374
const { files: browsedFiles, isLoading: isLoadingFiles, error: browseError } = useBrowseStorage(containerUrlToBrowse, refreshKey);
185-
const [permissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
186-
const [selectedFileForPermissions, setSelectedFileForPermissions] =
187-
useState<FileItemData | null>(null);
188-
const [permissions, setPermissions] = useState<Permission[]>([]);
189-
const [sidebarOpen, setSidebarOpen] = useState(false);
190-
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
191-
const [fileUploadTrigger, setFileUploadTrigger] = useState(0);
192-
const [showRenameDialog, setShowRenameDialog] = useState(false);
193-
const [fileToRename, setFileToRename] = useState<FileItemData | null>(null);
194-
const [showPreviewModal, setShowPreviewModal] = useState(false);
195-
const [fileToPreview, setFileToPreview] = useState<FileItemData | null>(null);
375+
196376

197377
const handleFolderCreated = () => {
198378
setRefreshKey((prev) => prev + 1);
@@ -211,24 +391,42 @@ export default function FileManager() {
211391
setRefreshKey((prev) => prev + 1);
212392
};
213393

394+
const handleCopy = async (file: FileItemData) => {
395+
if (!file) {
396+
return;
397+
}
398+
399+
const toastId = toast.loading(`Copying "${file.name}"...`);
400+
try {
401+
const { fetch: fetchFn } = getAuthenticatedSession();
402+
if (file.type === "folder") {
403+
await copyFolderResource(file, fetchFn);
404+
} else {
405+
await copyFileResource(file, fetchFn);
406+
}
407+
toast.success(`Copied "${file.name}"`, { id: toastId });
408+
setRefreshKey((prev) => prev + 1);
409+
} catch (error) {
410+
console.error("Failed to copy resource:", error);
411+
toast.error(
412+
error instanceof Error ? `Failed to copy: ${error.message}` : "Failed to copy resource",
413+
{ id: toastId }
414+
);
415+
}
416+
};
417+
214418
const handlePreview = (file: FileItemData) => {
215419
setFileToPreview(file);
216420
setShowPreviewModal(true);
217421
};
218422

219-
console.log("storages:", storages);
220-
221-
222-
// const storageFiles: FileItemData[] = filterProfileItems(storages).map((storage) => ({
223423
const storageFiles: FileItemData[] = storages.map((storage) => ({
224424
id: storage.id,
225425
name: storage.name,
226426
type: "folder" as const,
227427
url: storage.url,
228428
}));
229-
console.log("storageFiles:", storageFiles);
230429

231-
// const filteredFiles = filterProfileItems(browsedFiles);
232430
const displayFiles = selectedStorageId ? browsedFiles : storageFiles;
233431

234432
const selectedStorage = storages.find((s) => s.id === selectedStorageId);
@@ -391,21 +589,26 @@ export default function FileManager() {
391589
onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)}
392590
/>
393591
<main className="flex flex-1 flex-col overflow-hidden">
394-
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
592+
<div className="flex-shrink-0">
593+
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
594+
</div>
395595
{isBrowsing ? (
396596
<div className="flex flex-1 items-center justify-center">
397597
<LoadingSpinner size="md" text="Loading folder contents..." />
398598
</div>
399599
) : (
400-
<FileList
401-
files={displayFiles}
402-
currentPath={currentPath}
403-
onFileSelect={handleFileSelect}
404-
onFileDoubleClick={handleFileDoubleClick}
405-
onFileRename={handleRename}
406-
onFilePreview={handlePreview}
407-
selectedFileIds={selectedFileIds}
408-
/>
600+
<div className="flex-1 min-h-0 overflow-hidden">
601+
<FileList
602+
files={displayFiles}
603+
currentPath={currentPath}
604+
onFileSelect={handleFileSelect}
605+
onFileDoubleClick={handleFileDoubleClick}
606+
onFileRename={handleRename}
607+
onFilePreview={handlePreview}
608+
onFileCopy={handleCopy}
609+
selectedFileIds={selectedFileIds}
610+
/>
611+
</div>
409612
)}
410613
</main>
411614
</div>
@@ -455,4 +658,3 @@ export default function FileManager() {
455658
</AuthWrapper>
456659
);
457660
}
458-

0 commit comments

Comments
 (0)