diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index bc76d8bcffcfc..598053265b811 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -11,9 +11,11 @@ import SettingsModel from './models/Setting.ts' import router from './router/router.ts' import RouterService from './services/RouterService.ts' import SettingsService from './services/Settings.js' +import registerUploadBeforeUnload from './services/UploadBeforeUnload.ts' import { getPinia } from './store/index.ts' __webpack_nonce__ = getCSPNonce() +registerUploadBeforeUnload() // Init private and public Files namespace window.OCA.Files = window.OCA.Files ?? {} diff --git a/apps/files/src/services/UploadBeforeUnload.spec.ts b/apps/files/src/services/UploadBeforeUnload.spec.ts new file mode 100644 index 0000000000000..dc0874719b676 --- /dev/null +++ b/apps/files/src/services/UploadBeforeUnload.spec.ts @@ -0,0 +1,78 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getUploader = vi.hoisted(() => vi.fn()) +const t = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/upload', () => ({ + getUploader, + UploaderStatus: { + IDLE: 0, + UPLOADING: 1, + PAUSED: 2, + }, +})) + +vi.mock('@nextcloud/l10n', () => ({ + t, +})) + +describe('registerUploadBeforeUnload', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + async function getBeforeUnloadHandler(): Promise<(event: BeforeUnloadEvent) => void> { + const addListener = vi.spyOn(window, 'addEventListener') + const { default: register } = await import('./UploadBeforeUnload.ts') + register() + const call = addListener.mock.calls.find((c) => c[0] === 'beforeunload') + expect(call).toBeDefined() + addListener.mockRestore() + return call![1] as (event: BeforeUnloadEvent) => void + } + + it('does not prevent unload or translate when uploader is idle', async () => { + getUploader.mockReturnValue({ info: { status: 0 } }) + const handler = await getBeforeUnloadHandler() + const event = new Event('beforeunload') as BeforeUnloadEvent + const preventDefault = vi.spyOn(event, 'preventDefault') + handler(event) + expect(preventDefault).not.toHaveBeenCalled() + expect(t).not.toHaveBeenCalled() + }) + + it.each([ + ['uploading', 1], + ['paused', 2], + ] as const)('sets leave warning when uploader is %s', async (_label, status) => { + t.mockReturnValue('leave-warning') + getUploader.mockReturnValue({ info: { status } }) + const handler = await getBeforeUnloadHandler() + const event = new Event('beforeunload') as BeforeUnloadEvent + const preventDefault = vi.spyOn(event, 'preventDefault') + handler(event) + expect(preventDefault).toHaveBeenCalled() + expect(t).toHaveBeenCalledWith( + 'files', + 'File uploads are still in progress. Leaving the page will cancel them.', + ) + }) + + it('adds only one beforeunload listener when register is called multiple times', async () => { + getUploader.mockReturnValue({ info: { status: 0 } }) + const addListener = vi.spyOn(window, 'addEventListener') + const { default: register } = await import('./UploadBeforeUnload.ts') + register() + register() + register() + const beforeUnloadCalls = addListener.mock.calls.filter((c) => c[0] === 'beforeunload') + expect(beforeUnloadCalls).toHaveLength(1) + addListener.mockRestore() + }) +}) diff --git a/apps/files/src/services/UploadBeforeUnload.ts b/apps/files/src/services/UploadBeforeUnload.ts new file mode 100644 index 0000000000000..f2a331842886b --- /dev/null +++ b/apps/files/src/services/UploadBeforeUnload.ts @@ -0,0 +1,26 @@ +import { t } from '@nextcloud/l10n' +import { getUploader, UploaderStatus } from '@nextcloud/upload' + +let registered = false +function onBeforeUnload(event: BeforeUnloadEvent): void { + const uploader = getUploader() + if (uploader.info.status === UploaderStatus.IDLE) { + return + } + event.preventDefault() + event.returnValue = t( + 'files', + 'File uploads are still in progress. Leaving the page will cancel them.', + ) +} + +/** +* Warn before closing or navigating away while uploads are running or paused. +*/ +export default function registerUploadBeforeUnload(): void { + if (registered) { + return + } + registered = true + window.addEventListener('beforeunload', onBeforeUnload) +} diff --git a/apps/files_sharing/src/init-public.ts b/apps/files_sharing/src/init-public.ts index b59389cb5c22e..5656f892ff965 100644 --- a/apps/files_sharing/src/init-public.ts +++ b/apps/files_sharing/src/init-public.ts @@ -10,6 +10,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { getNavigation } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import RouterService from '../../files/src/services/RouterService.ts' +import registerUploadBeforeUnload from '../../files/src/services/UploadBeforeUnload.ts' import registerFileDropView from './files_views/publicFileDrop.ts' import registerPublicFileShareView from './files_views/publicFileShare.ts' import registerPublicShareView from './files_views/publicShare.ts' @@ -19,6 +20,7 @@ import logger from './services/logger.ts' registerFileDropView() registerPublicShareView() registerPublicFileShareView() +registerUploadBeforeUnload() // Get the current view from state and set it active const view = loadState('files_sharing', 'view')