diff --git a/src/components/files/dialogs/delete-confirm-dialog.tsx b/src/components/files/dialogs/delete-confirm-dialog.tsx index 322e1157..933e3082 100644 --- a/src/components/files/dialogs/delete-confirm-dialog.tsx +++ b/src/components/files/dialogs/delete-confirm-dialog.tsx @@ -18,6 +18,7 @@ interface DeleteConfirmDialogProps { isPending: boolean operation?: OperationProgressState | null onCancelOperation?: () => void + onDismissOperation?: () => void } export function DeleteConfirmDialog({ @@ -28,6 +29,7 @@ export function DeleteConfirmDialog({ isPending, operation, onCancelOperation, + onDismissOperation, }: DeleteConfirmDialogProps) { const { t } = useTranslation() const running = !!operation @@ -49,7 +51,11 @@ export function DeleteConfirmDialog({ {!running && {t('files.trashConfirmDescription', { count })}} {operation ? ( - {})} /> + {})} + onClose={onDismissOperation} + /> ) : ( + {operation.finished ? ( + + ) : ( + + )} ) diff --git a/src/components/files/file-manager-dialogs.test.tsx b/src/components/files/file-manager-dialogs.test.tsx new file mode 100644 index 00000000..eae0116d --- /dev/null +++ b/src/components/files/file-manager-dialogs.test.tsx @@ -0,0 +1,106 @@ +import { cleanup, render, screen } from '@testing-library/react' +import type React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OperationProgressState } from './dialogs/operation-progress' +import { FileManagerDialogs } from './file-manager-dialogs' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock('@/components/ui/progress', () => ({ + Progress: ({ value }: { value?: number }) =>
{value}
, +})) + +vi.mock('./dialogs/rename-dialog', () => ({ + RenameDialog: () => null, +})) + +vi.mock('./dialogs/new-folder-dialog', () => ({ + NewFolderDialog: () => null, +})) + +vi.mock('./dialogs/name-conflict-dialog', () => ({ + NameConflictDialog: () => null, +})) + +vi.mock('./dialogs/share-dialog', () => ({ + ShareDialog: () => null, +})) + +vi.mock('./dialogs/delete-confirm-dialog', () => ({ + DeleteConfirmDialog: () => null, +})) + +vi.mock('./dialogs/move-dialog', () => ({ + MoveDialog: () => null, +})) + +afterEach(cleanup) + +function failedOperation(): OperationProgressState { + return { + title: 'Restore', + total: 2, + completed: 2, + currentName: '', + cancelRequested: false, + finished: true, + failures: [{ name: 'Archive', message: 'Permission denied' }], + } +} + +describe('FileManagerDialogs', () => { + it('shows failure details in the generic batch operation dialog', () => { + render( + , + ) + + expect(screen.getAllByText('Restore').length).toBeGreaterThan(0) + expect(screen.getByText('Archive')).toBeTruthy() + expect(screen.getByText('Permission denied')).toBeTruthy() + expect(screen.getByText('common.close')).toBeTruthy() + }) +}) diff --git a/src/components/files/file-manager-dialogs.tsx b/src/components/files/file-manager-dialogs.tsx index a626ea60..cd8c30ae 100644 --- a/src/components/files/file-manager-dialogs.tsx +++ b/src/components/files/file-manager-dialogs.tsx @@ -22,6 +22,7 @@ interface FileManagerDialogsProps { deleteTargetIds: string[] operation: OperationProgressState | null onOperationCancel: () => void + onOperationDismiss: () => void onDeleteClose: () => void onDeleteConfirm: () => void deletePending: boolean @@ -62,6 +63,7 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) { count={props.deleteTargetIds.length} operation={props.deleteTargetIds.length > 0 ? props.operation : null} onCancelOperation={props.onOperationCancel} + onDismissOperation={props.onOperationDismiss} onOpenChange={(open) => { if (!open) props.onDeleteClose() }} @@ -79,6 +81,7 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) { excludeIds={props.moveTargetIds} operation={props.moveTargetIds.length > 0 ? props.operation : null} onCancelOperation={props.onOperationCancel} + onDismissOperation={props.onOperationDismiss} /> @@ -86,7 +89,13 @@ export function FileManagerDialogs(props: FileManagerDialogsProps) { {props.operation?.title} - {props.operation && } + {props.operation && ( + + )} diff --git a/src/components/files/file-manager.tsx b/src/components/files/file-manager.tsx index 8bc58c19..d41ef75e 100644 --- a/src/components/files/file-manager.tsx +++ b/src/components/files/file-manager.tsx @@ -406,7 +406,15 @@ export function FileManager({ ) => { operationCancelRef.current = false const namesById = new Map(items.map((item) => [item.id, item.name])) - setOperationState({ title, total: ids.length, completed: 0, currentName: '', cancelRequested: false }) + setOperationState({ + title, + total: ids.length, + completed: 0, + currentName: '', + cancelRequested: false, + finished: false, + failures: [], + }) const result = await runSequentialOperation({ items: ids, @@ -417,18 +425,30 @@ export function FileManager({ onItemComplete: (_id, index) => { setOperationState((state) => (state ? { ...state, completed: index + 1 } : state)) }, - onItemFailure: (_id, _error, index) => { - setOperationState((state) => (state ? { ...state, completed: index + 1 } : state)) + onItemFailure: (id, error, index) => { + setOperationState((state) => + state + ? { + ...state, + completed: index + 1, + failures: [ + ...state.failures, + { name: namesById.get(id) ?? id, message: error.message || t('common.error') }, + ], + } + : state, + ) }, runItem: action, }) invalidation() - setOperationState(null) if (result.failed.length > 0) { + setOperationState((state) => (state ? { ...state, finished: true, currentName: '' } : state)) toast.error(t('files.operationFailedSummary', { failed: result.failed.length, total: ids.length })) return result } + setOperationState(null) if (result.cancelled) { toast.info(t('files.operationCancelled', { completed: result.completed, total: ids.length })) return result @@ -444,6 +464,13 @@ export function FileManager({ setOperationState((state) => (state ? { ...state, cancelRequested: true } : state)) } + function dismissOperation() { + setOperationState(null) + if (deleteTargetIds.length > 0) setDeleteTargetIds([]) + if (moveTargetIds.length > 0) setMoveTargetIds([]) + setRowSelection({}) + } + function handleDndDrop(fileIds: string[], targetFolderId: string) { conflict.reset() runFileOperation( @@ -642,6 +669,7 @@ export function FileManager({ deleteTargetIds={deleteTargetIds} operation={operationState} onOperationCancel={requestOperationCancel} + onOperationDismiss={dismissOperation} onDeleteClose={() => setDeleteTargetIds([])} onDeleteConfirm={() => { const ids = [...deleteTargetIds] diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 75dfd916..0f87c044 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -90,6 +90,7 @@ "files.operationCanceling": "Canceling...", "files.operationCancelled": "Canceled after {{completed}} / {{total}} item(s)", "files.operationFailedSummary": "{{failed}} / {{total}} item(s) failed", + "files.operationFailuresTitle": "{{count}} failed item(s)", "files.uploadBatchCancelled": "{{count}} remaining upload(s) cancelled", "tasks.title": "Tasks", "tasks.count": "{{count}} tasks", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8bbbbf09..d35d4a51 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -90,6 +90,7 @@ "files.operationCanceling": "正在取消...", "files.operationCancelled": "已取消,完成 {{completed}} / {{total}} 项", "files.operationFailedSummary": "{{failed}} / {{total}} 项失败", + "files.operationFailuresTitle": "{{count}} 个失败项目", "files.uploadBatchCancelled": "已取消剩余 {{count}} 个上传", "tasks.title": "任务", "tasks.count": "{{count}} 个任务", diff --git a/src/routes/_authenticated/trash/index.tsx b/src/routes/_authenticated/trash/index.tsx index baf5c515..66e43acf 100644 --- a/src/routes/_authenticated/trash/index.tsx +++ b/src/routes/_authenticated/trash/index.tsx @@ -52,7 +52,15 @@ function TrashPage() { ): Promise { operationCancelRef.current = false const namesById = new Map(items.map((item) => [item.id, item.name])) - setOperationState({ title, total: ids.length, completed: 0, currentName: '', cancelRequested: false }) + setOperationState({ + title, + total: ids.length, + completed: 0, + currentName: '', + cancelRequested: false, + finished: false, + failures: [], + }) const result = await runSequentialOperation({ items: ids, @@ -63,16 +71,28 @@ function TrashPage() { onItemComplete: (_id, index) => { setOperationState((state) => (state ? { ...state, completed: index + 1 } : state)) }, - onItemFailure: (_id, _error, index) => { - setOperationState((state) => (state ? { ...state, completed: index + 1 } : state)) + onItemFailure: (id, error, index) => { + setOperationState((state) => + state + ? { + ...state, + completed: index + 1, + failures: [ + ...state.failures, + { name: namesById.get(id) ?? id, message: error.message || t('common.error') }, + ], + } + : state, + ) }, runItem, }) - setOperationState(null) if (result.failed.length > 0) { + setOperationState((state) => (state ? { ...state, finished: true, currentName: '' } : state)) throw new Error(t('files.operationFailedSummary', { failed: result.failed.length, total: ids.length })) } + setOperationState(null) if (result.cancelled) { toast.info(t('files.operationCancelled', { completed: result.completed, total: ids.length })) } @@ -83,6 +103,13 @@ function TrashPage() { setOperationState((state) => (state ? { ...state, cancelRequested: true } : state)) } + function dismissOperation() { + setOperationState(null) + setConfirmDialog(null) + setPendingDeleteIds([]) + setSelectedIds(new Set()) + } + async function runRestore(ids: string[]) { conflict.reset() const showApplyToAll = ids.length > 1 @@ -254,7 +281,11 @@ function TrashPage() { )} {operationState ? ( - + ) : (