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:
Linus Rath
2026-10-11 16:40:32 +02:00
parent cd5fe770e4
commit e60840c0e6
4 changed files with 80 additions and 12 deletions
+13 -7
View File
@@ -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({
+2
View File
@@ -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",
+42
View File
@@ -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
View File
@@ -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) => {