From d7b0306a00309498577ff9dc99526aa50ef82df7 Mon Sep 17 00:00:00 2001 From: mykh-hailo Date: Tue, 31 Mar 2026 03:40:39 +0200 Subject: [PATCH 1/3] fix: show confirm dialog on closing upload tab Signed-off-by: mykh-hailo --- apps/files/src/main.ts | 2 ++ apps/files/src/services/UploadBeforeUnload.ts | 24 +++++++++++++++++++ apps/files_sharing/src/init-public.ts | 2 ++ 3 files changed, 28 insertions(+) create mode 100644 apps/files/src/services/UploadBeforeUnload.ts diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index bc76d8bcffcfc..8e3b877b6dfc0 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -12,8 +12,10 @@ import router from './router/router.ts' import RouterService from './services/RouterService.ts' import SettingsService from './services/Settings.js' import { getPinia } from './store/index.ts' +import registerUploadBeforeUnload from './services/UploadBeforeUnload.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.ts b/apps/files/src/services/UploadBeforeUnload.ts new file mode 100644 index 0000000000000..b04f6b27500be --- /dev/null +++ b/apps/files/src/services/UploadBeforeUnload.ts @@ -0,0 +1,24 @@ +import { getUploader, UploaderStatus } from '@nextcloud/upload' +import { t } from '@nextcloud/l10n' +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) +} \ No newline at end of file diff --git a/apps/files_sharing/src/init-public.ts b/apps/files_sharing/src/init-public.ts index b59389cb5c22e..cdb9115485ae2 100644 --- a/apps/files_sharing/src/init-public.ts +++ b/apps/files_sharing/src/init-public.ts @@ -15,10 +15,12 @@ import registerPublicFileShareView from './files_views/publicFileShare.ts' import registerPublicShareView from './files_views/publicShare.ts' import router from './router/index.ts' import logger from './services/logger.ts' +import registerUploadBeforeUnload from '../../files/src/services/UploadBeforeUnload.ts' registerFileDropView() registerPublicShareView() registerPublicFileShareView() +registerUploadBeforeUnload() // Get the current view from state and set it active const view = loadState('files_sharing', 'view') From f993675188a60e5a20bf96f9bf04cc55085256c8 Mon Sep 17 00:00:00 2001 From: mykh-hailo Date: Tue, 31 Mar 2026 03:52:10 +0200 Subject: [PATCH 2/3] chore: add test for confirmation to close upload tab Signed-off-by: mykh-hailo --- .../src/services/UploadBeforeUnload.spec.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/files/src/services/UploadBeforeUnload.spec.ts diff --git a/apps/files/src/services/UploadBeforeUnload.spec.ts b/apps/files/src/services/UploadBeforeUnload.spec.ts new file mode 100644 index 0000000000000..9d345ccfa08da --- /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() + }) +}) \ No newline at end of file From c2e253bdda9b181be0dd2a2b2883acba51dac3ea Mon Sep 17 00:00:00 2001 From: mykh-hailo Date: Tue, 31 Mar 2026 04:05:36 +0200 Subject: [PATCH 3/3] fix: lint error Signed-off-by: mykh-hailo --- apps/files/src/main.ts | 2 +- .../src/services/UploadBeforeUnload.spec.ts | 2 +- apps/files/src/services/UploadBeforeUnload.ts | 34 ++++++++++--------- apps/files_sharing/src/init-public.ts | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 8e3b877b6dfc0..598053265b811 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -11,8 +11,8 @@ 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 { getPinia } from './store/index.ts' import registerUploadBeforeUnload from './services/UploadBeforeUnload.ts' +import { getPinia } from './store/index.ts' __webpack_nonce__ = getCSPNonce() registerUploadBeforeUnload() diff --git a/apps/files/src/services/UploadBeforeUnload.spec.ts b/apps/files/src/services/UploadBeforeUnload.spec.ts index 9d345ccfa08da..dc0874719b676 100644 --- a/apps/files/src/services/UploadBeforeUnload.spec.ts +++ b/apps/files/src/services/UploadBeforeUnload.spec.ts @@ -75,4 +75,4 @@ describe('registerUploadBeforeUnload', () => { expect(beforeUnloadCalls).toHaveLength(1) addListener.mockRestore() }) -}) \ No newline at end of file +}) diff --git a/apps/files/src/services/UploadBeforeUnload.ts b/apps/files/src/services/UploadBeforeUnload.ts index b04f6b27500be..f2a331842886b 100644 --- a/apps/files/src/services/UploadBeforeUnload.ts +++ b/apps/files/src/services/UploadBeforeUnload.ts @@ -1,24 +1,26 @@ -import { getUploader, UploaderStatus } from '@nextcloud/upload' 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.', - ) + 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) -} \ No newline at end of file + 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 cdb9115485ae2..5656f892ff965 100644 --- a/apps/files_sharing/src/init-public.ts +++ b/apps/files_sharing/src/init-public.ts @@ -10,12 +10,12 @@ 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' import router from './router/index.ts' import logger from './services/logger.ts' -import registerUploadBeforeUnload from '../../files/src/services/UploadBeforeUnload.ts' registerFileDropView() registerPublicShareView()