Skip to content

Commit 8a33e30

Browse files
90 uploading same file (#95)
* Detect name conflicts before file upload * Add replace and keep-both upload helpers * Add upload conflict dialog UI * fix naming style when keeping both uploads * Add useUploadConflictPrompt hook for shared upload conflicts * Use upload conflict prompt for picker and drag-and-drop * add eol-last * Detect upload conflicts from cached container listing * Use create-only putFile for keep-both uploads * Use putFile for new uploads and retry on name conflicts * Use saveFileInContainer for create-only uploads
1 parent 05771a5 commit 8a33e30

6 files changed

Lines changed: 376 additions & 28 deletions

File tree

‎app/components/FileUploadHandler.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { useRef, useEffect, type InputHTMLAttributes } from "react";
44
import { toast } from "@/components/ui/toast";
55
import {
66
getAuthenticatedSession,
7-
uploadFilesToContainer,
87
uploadFolderFilesToContainer,
98
FolderUploadFile,
109
} from "../lib/helpers";
10+
import { useUploadConflictPrompt } from "./useUploadConflictPrompt";
1111

1212
type FileWithRelativePath = File & {
1313
webkitRelativePath?: string;
@@ -28,6 +28,8 @@ export default function FileUploadHandler({
2828
const fileInputRef = useRef<HTMLInputElement>(null);
2929
const folderInputRef = useRef<HTMLInputElement>(null);
3030

31+
const { uploadFilesWithConflictPrompt, conflictDialog } = useUploadConflictPrompt();
32+
3133
useEffect(() => {
3234
if (triggerUpload && triggerUpload > 0 && fileInputRef.current) {
3335
fileInputRef.current.click();
@@ -58,11 +60,12 @@ export default function FileUploadHandler({
5860
e.target.value = "";
5961
return;
6062
}
63+
6164
try {
62-
const { uploadedFiles, failedFiles } = await uploadFilesToContainer(
65+
const { uploadedFiles, failedFiles } = await uploadFilesWithConflictPrompt(
6366
Array.from(files),
6467
currentContainerUrl,
65-
fetchFn
68+
fetchFn,
6669
);
6770

6871
if (uploadedFiles.length > 0) {
@@ -171,6 +174,7 @@ export default function FileUploadHandler({
171174
className="hidden"
172175
onChange={handleFolderChange}
173176
/>
177+
{conflictDialog}
174178
</>
175179
);
176180
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import {
4+
AlertDialog,
5+
AlertDialogAction,
6+
AlertDialogCancel,
7+
AlertDialogContent,
8+
AlertDialogDescription,
9+
AlertDialogFooter,
10+
AlertDialogHeader,
11+
AlertDialogTitle,
12+
AlertDialogMedia,
13+
} from "@/components/ui/alert-dialog";
14+
import { FileWarning } from "lucide-react";
15+
16+
interface UploadConflictDialogProps {
17+
isOpen: boolean;
18+
fileName: string | null;
19+
onReplace: () => void;
20+
onKeepBoth: () => void;
21+
onCancel: () => void;
22+
}
23+
24+
export default function UploadConflictDialog({
25+
isOpen,
26+
fileName,
27+
onReplace,
28+
onKeepBoth,
29+
onCancel,
30+
}: UploadConflictDialogProps) {
31+
return (
32+
<AlertDialog
33+
open={isOpen}
34+
onOpenChange={(open) => {
35+
if (!open) onCancel();
36+
}}
37+
>
38+
<AlertDialogContent size="default">
39+
<AlertDialogHeader>
40+
<AlertDialogMedia className="bg-primary/10 text-primary">
41+
<FileWarning />
42+
</AlertDialogMedia>
43+
<AlertDialogTitle>File already exists</AlertDialogTitle>
44+
<AlertDialogDescription>
45+
A file named{" "}
46+
<span className="font-medium text-foreground">&quot;{fileName}&quot;</span>{" "}
47+
already exists in this folder. Do you want to replace it or keep both?
48+
</AlertDialogDescription>
49+
</AlertDialogHeader>
50+
<AlertDialogFooter>
51+
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
52+
<AlertDialogAction variant="outline" onClick={onKeepBoth}>
53+
Keep both
54+
</AlertDialogAction>
55+
<AlertDialogAction onClick={onReplace}>Replace</AlertDialogAction>
56+
</AlertDialogFooter>
57+
</AlertDialogContent>
58+
</AlertDialog>
59+
)
60+
}

‎app/components/file-manager/FileManagerContent.tsx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,20 @@ import ErrorDisplay from "../shared/ErrorDisplay";
3232
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
3333
import {
3434
getAuthenticatedSession,
35-
uploadFilesToContainer,
3635
uploadFolderFilesToContainer,
3736
processDragDropItems,
3837
hasFiles as hasFilesInDrag,
3938
isUnsupportedFolderDrag,
4039
} from "@/app/lib/helpers";
4140
import { isDialog } from "./types/fileActions";
42-
import { useFileManagerNavigation, useFileManagerBrowse, useFileManagerActions, useFileManagerSelection, useFileManagerDialogs } from "./context/fileManagerContext";
41+
import {
42+
useFileManagerNavigation,
43+
useFileManagerBrowse,
44+
useFileManagerActions,
45+
useFileManagerSelection,
46+
useFileManagerDialogs
47+
} from "./context/fileManagerContext";
48+
import { useUploadConflictPrompt } from "../useUploadConflictPrompt";
4349

4450
type ContextMenuState =
4551
| { type: "new"; position: { x: number; y: number } }
@@ -100,6 +106,7 @@ export default function FileManagerContent() {
100106
const [contextMenuState, setContextMenuState] = useState<ContextMenuState | null>(null);
101107

102108
const closeContextMenu = () => setContextMenuState(null);
109+
const { uploadFilesWithConflictPrompt, conflictDialog } = useUploadConflictPrompt();
103110

104111
/** Require a selected container before create/upload actions. */
105112
const ensureStorageSelected = () => {
@@ -210,7 +217,12 @@ export default function FileManagerContent() {
210217

211218
if (singleFiles.length > 0) {
212219
try {
213-
const { uploadedFiles, failedFiles } = await uploadFilesToContainer(singleFiles, containerUrlToBrowse, fetchFn);
220+
const { uploadedFiles, failedFiles } = await uploadFilesWithConflictPrompt(
221+
singleFiles,
222+
containerUrlToBrowse,
223+
fetchFn,
224+
)
225+
214226
if (uploadedFiles.length > 0) {
215227
uploadedSomething = true;
216228
toast.add({
@@ -546,6 +558,7 @@ export default function FileManagerContent() {
546558
triggerUpload={fileUploadTrigger}
547559
triggerFolderUpload={folderUploadTrigger}
548560
/>
561+
{conflictDialog}
549562
{contextMenuState && (
550563
<ContextMenu
551564
position={contextMenuState.position}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
3+
import { useCallback, useRef, useState } from "react";
4+
import UploadConflictDialog from "./UploadConflictDialog";
5+
import {
6+
findUploadConflicts,
7+
uploadFilesToContainer,
8+
uploadFileWithConflictChoice,
9+
type UploadConflict,
10+
type UploadConflictChoice,
11+
type UploadResult,
12+
} from "../lib/helpers";
13+
14+
export function useUploadConflictPrompt() {
15+
const [activeConflict, setActiveConflict] = useState<UploadConflict | null>(null);
16+
const conflictResolverRef = useRef<((choice: UploadConflictChoice) => void) | null>(null);
17+
18+
const resolveConflictChoice = useCallback((choice: UploadConflictChoice) => {
19+
const resolve = conflictResolverRef.current;
20+
conflictResolverRef.current = null;
21+
setActiveConflict(null);
22+
resolve?.(choice);
23+
}, []);
24+
25+
const askConflictChoice = useCallback(
26+
(conflict: UploadConflict) =>
27+
new Promise<UploadConflictChoice>((resolve) => {
28+
conflictResolverRef.current = resolve;
29+
setActiveConflict(conflict);
30+
}),
31+
[],
32+
);
33+
34+
const uploadFilesWithConflictPrompt = useCallback(
35+
async (
36+
files: File[],
37+
currentContainerUrl: string,
38+
fetchFn: typeof fetch,
39+
): Promise<UploadResult> => {
40+
const { newFiles, conflicts } = await findUploadConflicts(
41+
files,
42+
currentContainerUrl,
43+
fetchFn,
44+
);
45+
46+
const uploadedFiles: string[] = [];
47+
const failedFiles: string[] = [];
48+
49+
if (newFiles.length > 0) {
50+
const result = await uploadFilesToContainer(
51+
newFiles,
52+
currentContainerUrl,
53+
fetchFn,
54+
);
55+
uploadedFiles.push(...result.uploadedFiles);
56+
failedFiles.push(...result.failedFiles);
57+
}
58+
59+
for (const conflict of conflicts) {
60+
const choice = await askConflictChoice(conflict);
61+
if (choice === "cancel") {
62+
continue;
63+
}
64+
65+
try {
66+
const { uploadedName } = await uploadFileWithConflictChoice(
67+
conflict,
68+
choice,
69+
currentContainerUrl,
70+
fetchFn,
71+
);
72+
uploadedFiles.push(uploadedName);
73+
} catch {
74+
failedFiles.push(conflict.existingName);
75+
}
76+
}
77+
78+
return { uploadedFiles, failedFiles };
79+
},
80+
[askConflictChoice],
81+
);
82+
83+
const conflictDialog = (
84+
<UploadConflictDialog
85+
isOpen={activeConflict !== null}
86+
fileName={activeConflict?.existingName ?? null}
87+
onReplace={() => resolveConflictChoice("replace")}
88+
onKeepBoth={() => resolveConflictChoice("keepBoth")}
89+
onCancel={() => resolveConflictChoice("cancel")}
90+
/>
91+
);
92+
93+
return {
94+
uploadFilesWithConflictPrompt,
95+
conflictDialog,
96+
};
97+
}

‎app/lib/helpers/copyUtils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const shouldSkipResourceCopy = (resourceUrl: string): boolean => {
5555
};
5656

5757

58-
const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
58+
export const resourceExists = async (url: string, fetchFn: typeof fetch): Promise<boolean> => {
5959
try {
6060
const response = await fetchFn(url, { method: "HEAD" });
6161
if (response.status === 404) {

0 commit comments

Comments
 (0)