Skip to content
Merged
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
317 changes: 198 additions & 119 deletions app/components/MoveDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useEffect } from "react";
import { useState, useEffect, useId } from "react";
import {
Dialog,
DialogContent,
Expand All @@ -11,15 +11,24 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getSolidDataset, getContainedResourceUrlAll, UrlString } from "@inrupt/solid-client";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/toast";
import { FileItemData } from "./FileItem";
import {
moveFileResource,
moveFileWithConflictChoice,
findMoveConflict,
getAuthenticatedSession,
decodeResourceNameFromUrl,
getParentContainerUrl,
ensureTrailingSlash,
folderUrlsEqual,
fetchContainerListing,
describeMoveError,
describeMoveResult,
type MoveResult,
} from "../lib/helpers";
import { useNameConflictPrompt } from "./useNameConflictPrompt";
import { loadContainerListing } from "../lib/cache";
import { Folder, AlertTriangle } from "lucide-react";

interface MoveDialogProps {
Expand All @@ -43,6 +52,17 @@ export default function MoveDialog({
const [isMoving, setIsMoving] = useState(false);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const [allFolders, setAllFolders] = useState<FileItemData[]>([]);
const [newName, setNewName] = useState("");
const nameInputId = useId();
const { askConflictChoice, conflictDialog } = useNameConflictPrompt();

const trimmedName = newName.trim();
// A slash would change which container the resource lands in, not just its
// name, so block it here.
const nameError =
newName.includes("/") || newName.includes("\\")
? "A name cannot contain / or \\"
: null;

// Recursively fetch all folders from a storage
const fetchAllFolders = async (containerUrl: string, fetchFn: typeof fetch): Promise<FileItemData[]> => {
Expand All @@ -57,24 +77,17 @@ export default function MoveDialog({
visited.add(normalizedUrl);

try {
const dataset = await getSolidDataset(normalizedUrl as UrlString, { fetch: fetchFn });
const containedResources = getContainedResourceUrlAll(dataset);

for (const resourceUrl of containedResources) {
if (resourceUrl.endsWith("/")) {
// It's a folder
const folderName = decodeResourceNameFromUrl(resourceUrl);

folders.push({
id: resourceUrl,
name: folderName,
type: "folder",
url: resourceUrl,
});
const listing = await loadContainerListing(normalizedUrl, () =>
fetchContainerListing(normalizedUrl, fetchFn),
);

// Recursively traverse subfolders
await traverse(resourceUrl);
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
Expand All @@ -92,6 +105,7 @@ export default function MoveDialog({
setIsMoving(false);
setIsLoadingFolders(true);
setAllFolders([]);
setNewName(file.name);

// Find the storage root for this file
const fileStorage = availableFolders.find(
Expand Down Expand Up @@ -129,24 +143,55 @@ export default function MoveDialog({
return;
}

let fetchFn: typeof fetch;
try {
({ fetch: fetchFn } = getAuthenticatedSession());
} catch {
toast.add({ title: "Not authenticated", type: "error" });
return;
}

setIsMoving(true);

try {
const { fetch: fetchFn } = getAuthenticatedSession();
await moveFileResource(file, selectedFolderUrl, fetchFn);
// Ask before transferring anything, so a clash does not cost a round trip
// of the file's bytes. The server's own 409 is still the backstop.
const conflict = await findMoveConflict(
selectedFolderUrl,
trimmedName,
fetchFn,
);

toast.add({ title: `Moved "${file.name}"`, type: "success" });
let result: MoveResult;

if (conflict) {
const choice = await askConflictChoice(conflict.existingName);
if (choice === "cancel") {
return;
}

result = await moveFileWithConflictChoice(
file,
selectedFolderUrl,
trimmedName,
conflict,
choice,
fetchFn,
);
} else {
result = await moveFileResource(file, selectedFolderUrl, fetchFn, trimmedName);
}

toast.add(describeMoveResult(result));
onClose();

// Notify parent to refresh
if (onMoved) {
onMoved(selectedFolderUrl);
onMoved(getParentContainerUrl(result.url));
}
} catch (error) {
console.error("Failed to move file:", error);
toast.add({ title: error instanceof Error
? `Failed to move: ${error.message}`
: "Failed to move file", type: "error" });
toast.add(describeMoveError(error));
} finally {
setIsMoving(false);
}
Expand All @@ -160,105 +205,139 @@ export default function MoveDialog({

if (!file) return null;

// Filter out the current folder (where the file currently is)
// 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" && folder.url !== currentLocationUrl
(folder) => folder.type === "folder" && !folderUrlsEqual(folder.url, currentLocationUrl)
);

return (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Move</DialogTitle>
<DialogDescription className="truncate">
{file.name}
</DialogDescription>
</DialogHeader>
<main className="py-2" onKeyDown={handleKeyDown}>
{/* Current Location */}
<section className="mb-4">
<label className="block text-sm font-medium text-foreground mb-2">
Current location:
</label>
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-md border border-border">
<Folder className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-foreground">
{currentLocationUrl
? availableFolders.find(f => f.url === currentLocationUrl)?.name ||
(() => {
try {
const url = new URL(currentLocationUrl);
return url.pathname.split("/").filter(Boolean).pop() || currentLocationUrl;
} catch {
return currentLocationUrl;
}
})()
: "My Storages"}
</span>
</div>
</section>

{/* Available Folders */}
<section>
<label className="block text-sm font-medium text-foreground mb-2">
Select a destination:
</label>
{isLoadingFolders ? (
<div className="flex flex-col items-center justify-center gap-2 py-8">
<Spinner className="size-5" />
<p className="text-sm text-muted-foreground">Loading folders...</p>
</div>
) : filteredFolders.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
No folders available to move to
</div>
) : (
<div className="max-h-64 overflow-y-auto border border-border rounded-md">
{filteredFolders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => setSelectedFolderUrl(folder.url)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted transition-colors ${selectedFolderUrl === folder.url
? "bg-accent border-l-4 border-primary"
: "border-l-4 border-transparent"
}`}
<>
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Move</DialogTitle>
<DialogDescription className="truncate">
{file.name}
</DialogDescription>
</DialogHeader>
<main className="py-2" onKeyDown={handleKeyDown}>
{/* Name */}
<section className="mb-4">
<label
htmlFor={nameInputId}
className="block text-sm font-medium text-foreground mb-2"
>
Name:
</label>
<Input
id={nameInputId}
type="text"
value={newName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewName(e.target.value)
}
disabled={isMoving}
aria-invalid={nameError ? true : undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
/>
{nameError && (
<p
id={`${nameInputId}-error`}
className="mt-1 text-xs text-destructive"
role="alert"
>
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate">{folder.name}</span>
</button>
))}
</div>
)}
</section>
{nameError}
</p>
)}
</section>
{/* Current Location */}
<section className="mb-4">
<label className="block text-sm font-medium text-foreground mb-2">
Current location:
</label>
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-md border border-border">
<Folder className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-foreground">
{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;
}
})()
: "My Storages"}
</span>
</div>
</section>

{/* Available Folders */}
<section>
<label className="block text-sm font-medium text-foreground mb-2">
Select a destination:
</label>
{isLoadingFolders ? (
<div className="flex flex-col items-center justify-center gap-2 py-8">
<Spinner className="size-5" />
<p className="text-sm text-muted-foreground">Loading folders...</p>
</div>
) : filteredFolders.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
No folders available to move to
</div>
) : (
<div className="max-h-64 overflow-y-auto border border-border rounded-md">
{filteredFolders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => setSelectedFolderUrl(folder.url)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted transition-colors ${selectedFolderUrl === folder.url
? "bg-accent border-l-4 border-primary"
: "border-l-4 border-transparent"
}`}
>
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate">{folder.name}</span>
</button>
))}
</div>
)}
</section>

{filteredFolders.length > 0 && !selectedFolderUrl && (
<div className="mt-4 flex items-start gap-2 text-sm text-muted-foreground bg-accent border border-border rounded-md p-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 shrink-0 mt-0.5" />
<span>Select a location to show the folder path</span>
</div>
)}
</main>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
disabled={isMoving}
>
Cancel
</Button>
<Button
variant="default"
onClick={handleMove}
disabled={isMoving || !selectedFolderUrl}
aria-busy={isMoving}
>
{isMoving && <Spinner />}
Move
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{filteredFolders.length > 0 && !selectedFolderUrl && (
<div className="mt-4 flex items-start gap-2 text-sm text-muted-foreground bg-accent border border-border rounded-md p-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 shrink-0 mt-0.5" />
<span>Select a location to show the folder path</span>
</div>
)}
</main>
<DialogFooter>
<Button
variant="ghost"
onClick={onClose}
disabled={isMoving}
>
Cancel
</Button>
<Button
variant="default"
onClick={handleMove}
disabled={isMoving || !selectedFolderUrl || !trimmedName || nameError !== null}
aria-busy={isMoving}
>
{isMoving && <Spinner />}
Move
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{conflictDialog}
</>
);
}

Loading
Loading