mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix(files): say the upload was canceled instead of counting it as done
Cancelling an upload ended the loop like a normal finish, and the Files app then reported every picked file as uploaded. Uploads now return how many files went up and whether they were cancelled, so a cancel shows "Upload canceled" (with the count of files that made it, if any). Folder uploads also passed no cancel signal or progress callback to the blob upload, so Cancel did not stop the file in flight and the progress line stood still; they now behave like file uploads.
This commit is contained in:
@@ -7,7 +7,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, resourceServerRef } from "@/stores/file-store";
|
||||
import { useFileStore, resourceServerRef, type UploadResult } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -301,6 +301,14 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||
|
||||
const showUploadResult = useCallback(({ uploaded, canceled }: UploadResult) => {
|
||||
if (canceled) {
|
||||
toast.info(t("upload_canceled"), uploaded > 0 ? t("upload_canceled_partial", { count: uploaded }) : undefined);
|
||||
} else if (uploaded > 0) {
|
||||
toast.success(t("upload_success", { count: uploaded }));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
@@ -311,13 +319,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFiles(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
showUploadResult(await uploadFiles(files));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload]);
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadResult]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
@@ -329,13 +336,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFolder(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
showUploadResult(await uploadFolder(files));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"));
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload]);
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadResult]);
|
||||
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
|
||||
@@ -3623,6 +3623,8 @@
|
||||
"no_accounts": "No connected accounts.",
|
||||
"uploading": "Uploading...",
|
||||
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
|
||||
"upload_canceled": "Upload canceled",
|
||||
"upload_canceled_partial": "{count, plural, one {1 file was} other {# files were}} uploaded before you canceled",
|
||||
"upload_error": "Failed to upload file",
|
||||
"create_folder_success": "Folder created",
|
||||
"create_folder_error": "Failed to create folder",
|
||||
|
||||
@@ -504,3 +504,45 @@ describe('file-store folder tree and moves', () => {
|
||||
expect(await useFileStore.getState().revealNode('gone')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-store upload result', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({
|
||||
client: null,
|
||||
currentParentId: null,
|
||||
currentPath: '/',
|
||||
pathStack: [{ id: null, name: '' }],
|
||||
resources: [],
|
||||
uploadProgress: null,
|
||||
uploadAbortController: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports every file when the upload runs to the end', async () => {
|
||||
const client = makeMockClient();
|
||||
useFileStore.getState().initClient(client);
|
||||
const result = await useFileStore.getState().uploadFiles([
|
||||
new File(['a'], 'a.txt'), new File(['b'], 'b.txt'),
|
||||
]);
|
||||
expect(result).toEqual({ uploaded: 2, canceled: false });
|
||||
});
|
||||
|
||||
it('reports a cancel and only the files that made it before it', async () => {
|
||||
const client = makeMockClient();
|
||||
const upload = client.uploadBlob.bind(client);
|
||||
let calls = 0;
|
||||
client.uploadBlob = (async (file: File) => {
|
||||
calls++;
|
||||
// Cancel while the second file is on its way up.
|
||||
if (calls === 2) useFileStore.getState().cancelUpload();
|
||||
return upload(file);
|
||||
}) as typeof client.uploadBlob;
|
||||
useFileStore.getState().initClient(client);
|
||||
|
||||
const result = await useFileStore.getState().uploadFiles([
|
||||
new File(['a'], 'a.txt'), new File(['b'], 'b.txt'), new File(['c'], 'c.txt'),
|
||||
]);
|
||||
expect(result).toEqual({ uploaded: 1, canceled: true });
|
||||
expect(client._nodes().map(n => n.name)).toEqual(['a.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-5
@@ -40,6 +40,12 @@ interface ClipboardState {
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
/** How a batch upload ended: files that made it, and whether the user cancelled. */
|
||||
export interface UploadResult {
|
||||
uploaded: number;
|
||||
canceled: boolean;
|
||||
}
|
||||
|
||||
interface UndoAction {
|
||||
type: 'rename' | 'move';
|
||||
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
|
||||
@@ -93,8 +99,8 @@ interface FileState {
|
||||
refresh: () => Promise<void>;
|
||||
createDirectory: (name: string) => Promise<void>;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<void>;
|
||||
uploadFolder: (files: File[]) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<UploadResult>;
|
||||
uploadFolder: (files: File[]) => Promise<UploadResult>;
|
||||
cancelUpload: () => void;
|
||||
deleteResource: (name: string) => Promise<void>;
|
||||
deleteResources: (names: string[]) => Promise<void>;
|
||||
@@ -644,7 +650,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
|
||||
uploadFiles: async (files: File[]) => {
|
||||
const { client, currentParentId, resources } = get();
|
||||
if (!client) return;
|
||||
if (!client) return { uploaded: 0, canceled: false };
|
||||
let uploaded = 0;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
@@ -669,6 +676,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
|
||||
uploaded++;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
@@ -677,6 +685,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
return { uploaded, canceled: abortController.signal.aborted };
|
||||
},
|
||||
|
||||
cancelUpload: () => {
|
||||
@@ -689,7 +698,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
|
||||
uploadFolder: async (files: File[]) => {
|
||||
const { client, currentParentId } = get();
|
||||
if (!client || files.length === 0) return;
|
||||
if (!client || files.length === 0) return { uploaded: 0, canceled: false };
|
||||
let uploaded = 0;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
@@ -739,10 +749,17 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
try {
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
const idx = i;
|
||||
const { blobId, type } = await client.uploadBlob(file, {
|
||||
signal: abortController.signal,
|
||||
onProgress: (loaded, total) => {
|
||||
set({ uploadProgress: { name: relativePath, loaded, total, current: idx + 1, totalFiles } });
|
||||
},
|
||||
});
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
|
||||
uploaded++;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
@@ -751,6 +768,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
return { uploaded, canceled: abortController.signal.aborted };
|
||||
},
|
||||
|
||||
deleteResource: async (name: string) => {
|
||||
|
||||
Reference in New Issue
Block a user