From cc6e894ef09a0a9b9b96690e75c7dd9fd5834193 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 9 Feb 2026 15:41:59 +0100 Subject: [PATCH] feat: scope global state to major version of library Problem ------- In the past when we did breaking changes on the API we could not ensure that all apps loaded are already migrated and so some of them were still using the old files integrations. Because the global scope was not versioned those apps registered e.g. invalid actions on the files app causing the files app to break. Solution -------- With the changes on this PR we versioning the global state of the files integrations. So we know that the files app will only load integrations that are using the same major version of the library so prevent breaking the whole app. Note: This is not a breaking change API wise as no exposed API has changed, but any app now needs to use at least this version of the library to properly integrate into the files app. Signed-off-by: Ferdinand Thiessen --- __tests__/actions/fileAction.spec.ts | 5 ++- __tests__/actions/fileListAction.spec.ts | 5 ++- __tests__/dav/davProperties.spec.ts | 25 ++++++------ __tests__/filters/listFilter.spec.ts | 23 +++++------ __tests__/headers/listHeaders.spec.ts | 33 +++++++++++----- __tests__/index.spec.ts | 38 ------------------ __tests__/navigation.spec.ts | 15 +++---- __tests__/newMenu/newFileMenu.spec.ts | 50 ++++++++++++++++++++---- __tests__/sidebar/sidebarTab.spec.ts | 9 +++-- lib/actions/fileAction.ts | 12 ++++-- lib/actions/fileListAction.ts | 12 ++++-- lib/dav/davProperties.ts | 31 ++++++--------- lib/filters/functions.ts | 20 +++++----- lib/globalScope.ts | 44 +++++++++++++++++++++ lib/headers/listHeaders.ts | 13 +++--- lib/navigation/navigation.ts | 10 ++--- lib/newMenu/functions.ts | 9 ++--- lib/registry.ts | 50 ++++++++++++++++++------ lib/sidebar/SidebarAction.ts | 11 +++--- lib/sidebar/SidebarTab.ts | 11 +++--- lib/window.d.ts | 30 ++------------ 21 files changed, 257 insertions(+), 199 deletions(-) create mode 100644 lib/globalScope.ts diff --git a/__tests__/actions/fileAction.spec.ts b/__tests__/actions/fileAction.spec.ts index 409b6a1f5..39c62f25f 100644 --- a/__tests__/actions/fileAction.spec.ts +++ b/__tests__/actions/fileAction.spec.ts @@ -9,6 +9,7 @@ import type { Folder, Node } from '../../lib/node/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' import { DefaultType, getFileActions, registerFileAction } from '../../lib/actions/index.ts' +import { scopedGlobals } from '../../lib/globalScope.ts' import { getRegistry } from '../../lib/registry.ts' import logger from '../../lib/utils/logger.ts' @@ -16,7 +17,7 @@ const folder = {} as Folder const view = {} as View describe('FileActions init', () => { beforeEach(() => { - delete window._nc_fileactions + delete scopedGlobals.fileActions }) test('Getting empty uninitialized FileActions', () => { @@ -39,7 +40,7 @@ describe('FileActions init', () => { registerFileAction(action) - expect(window._nc_fileactions).toHaveLength(1) + expect(scopedGlobals.fileActions).toHaveLength(1) expect(getFileActions()).toHaveLength(1) expect(getFileActions()[0]).toStrictEqual(action) }) diff --git a/__tests__/actions/fileListAction.spec.ts b/__tests__/actions/fileListAction.spec.ts index 0c800a206..e8f5fe92f 100644 --- a/__tests__/actions/fileListAction.spec.ts +++ b/__tests__/actions/fileListAction.spec.ts @@ -9,6 +9,7 @@ import type { Folder } from '../../lib/node/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' import { getFileListActions, registerFileListAction } from '../../lib/actions/fileListAction.ts' +import { scopedGlobals } from '../../lib/globalScope.ts' import { getRegistry } from '../../lib/registry.ts' import logger from '../../lib/utils/logger.ts' @@ -28,11 +29,11 @@ function mockAction(id: string): IFileListAction { describe('FileListActions init', () => { beforeEach(() => { - delete window._nc_filelistactions + delete scopedGlobals.fileListActions }) test('Uninitialized file list actions', () => { - expect(window._nc_filelistactions).toBe(undefined) + expect(scopedGlobals.fileListActions).toBe(undefined) const actions = getFileListActions() expect(actions).toHaveLength(0) }) diff --git a/__tests__/dav/davProperties.spec.ts b/__tests__/dav/davProperties.spec.ts index 0297ac16d..606869d77 100644 --- a/__tests__/dav/davProperties.spec.ts +++ b/__tests__/dav/davProperties.spec.ts @@ -15,26 +15,27 @@ import { getRecentSearch, registerDavProperty, } from '../../lib/dav/davProperties.ts' +import { scopedGlobals } from '../../lib/globalScope.ts' import logger from '../../lib/utils/logger.ts' describe('DAV Properties', () => { beforeEach(() => { - delete window._nc_dav_properties - delete window._nc_dav_namespaces + delete scopedGlobals.davNamespaces + delete scopedGlobals.davProperties logger.error = vi.fn() logger.warn = vi.fn() }) test('getDavNameSpaces fall back to defaults', () => { - expect(window._nc_dav_namespaces).toBeUndefined() + expect(scopedGlobals.davNamespaces).toBeUndefined() const namespace = getDavNameSpaces() expect(namespace).toBeTruthy() Object.keys(defaultDavNamespaces).forEach((n) => expect(namespace.includes(n) && namespace.includes(defaultDavNamespaces[n])).toBe(true)) }) test('getDavProperties fall back to defaults', () => { - expect(window._nc_dav_properties).toBeUndefined() + expect(scopedGlobals.davProperties).toBeUndefined() const props = getDavProperties() expect(props).toBeTruthy() defaultDavProperties.forEach((p) => expect(props.includes(p)).toBe(true)) @@ -56,8 +57,8 @@ describe('DAV Properties', () => { }) test('registerDavProperty registers successfully', () => { - expect(window._nc_dav_namespaces).toBeUndefined() - expect(window._nc_dav_properties).toBeUndefined() + expect(scopedGlobals.davNamespaces).toBeUndefined() + expect(scopedGlobals.davProperties).toBeUndefined() expect(registerDavProperty('my:prop', { my: 'https://example.com/ns' })).toBe(true) expect(logger.warn).not.toBeCalled() @@ -67,8 +68,8 @@ describe('DAV Properties', () => { }) test('registerDavProperty fails when registered multiple times', () => { - expect(window._nc_dav_namespaces).toBeUndefined() - expect(window._nc_dav_properties).toBeUndefined() + expect(scopedGlobals.davNamespaces).toBeUndefined() + expect(scopedGlobals.davProperties).toBeUndefined() expect(registerDavProperty('my:prop', { my: 'https://example.com/ns' })).toBe(true) expect(registerDavProperty('my:prop')).toBe(false) @@ -80,8 +81,8 @@ describe('DAV Properties', () => { }) test('registerDavProperty fails with invalid props', () => { - expect(window._nc_dav_namespaces).toBeUndefined() - expect(window._nc_dav_properties).toBeUndefined() + expect(scopedGlobals.davNamespaces).toBeUndefined() + expect(scopedGlobals.davProperties).toBeUndefined() expect(registerDavProperty('my:prop:invalid', { my: 'https://example.com/ns' })).toBe(false) expect(logger.error).toBeCalled() @@ -95,8 +96,8 @@ describe('DAV Properties', () => { }) test('registerDavProperty fails with missing namespace', () => { - expect(window._nc_dav_namespaces).toBeUndefined() - expect(window._nc_dav_properties).toBeUndefined() + expect(scopedGlobals.davNamespaces).toBeUndefined() + expect(scopedGlobals.davProperties).toBeUndefined() expect(registerDavProperty('my:prop', { other: 'https://example.com/ns' })).toBe(false) expect(logger.error).toBeCalled() diff --git a/__tests__/filters/listFilter.spec.ts b/__tests__/filters/listFilter.spec.ts index 199cbc362..88ceac755 100644 --- a/__tests__/filters/listFilter.spec.ts +++ b/__tests__/filters/listFilter.spec.ts @@ -1,4 +1,4 @@ -/** +/* * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -7,6 +7,7 @@ import type { IFileListFilterChip } from '../../lib/filters/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' import { FileListFilter, getFileListFilters, registerFileListFilter, unregisterFileListFilter } from '../../lib/filters/index.ts' +import { scopedGlobals } from '../../lib/globalScope.ts' import { getRegistry } from '../../lib/registry.ts' class TestFilter extends FileListFilter { @@ -83,16 +84,16 @@ describe('File list filter class', () => { describe('File list filter functions', () => { beforeEach(() => { - delete window._nc_filelist_filters + delete scopedGlobals.fileListFilters }) test('can register a filter', () => { const filter = new FileListFilter('my:id', 50) registerFileListFilter(filter) - expect(window._nc_filelist_filters).toBeTypeOf('object') - expect(window._nc_filelist_filters!.has(filter.id)).toBe(true) - expect(window._nc_filelist_filters!.get(filter.id)).toBe(filter) + expect(scopedGlobals.fileListFilters).toBeTypeOf('object') + expect(scopedGlobals.fileListFilters!.has(filter.id)).toBe(true) + expect(scopedGlobals.fileListFilters!.get(filter.id)).toBe(filter) }) test('register a filter emits event', () => { @@ -101,7 +102,7 @@ describe('File list filter functions', () => { getRegistry().addEventListener('register:listFilter', spy) - expect(window._nc_filelist_filters).toBe(undefined) + expect(scopedGlobals.fileListFilters).toBe(undefined) registerFileListFilter(filter) expect(spy).toHaveBeenCalled() @@ -121,22 +122,22 @@ describe('File list filter functions', () => { const filter = new FileListFilter('my:id') registerFileListFilter(filter) - expect(window._nc_filelist_filters!.has(filter.id)).toBe(true) + expect(scopedGlobals.fileListFilters!.has(filter.id)).toBe(true) // test unregisterFileListFilter(filter.id) - expect(window._nc_filelist_filters!.has(filter.id)).toBe(false) + expect(scopedGlobals.fileListFilters!.has(filter.id)).toBe(false) }) test('unregister a filter twice does not throw', () => { const filter = new FileListFilter('my:id') registerFileListFilter(filter) - expect(window._nc_filelist_filters!.has(filter.id)).toBe(true) + expect(scopedGlobals.fileListFilters!.has(filter.id)).toBe(true) // test unregisterFileListFilter(filter.id) - expect(window._nc_filelist_filters!.has(filter.id)).toBe(false) + expect(scopedGlobals.fileListFilters!.has(filter.id)).toBe(false) expect(() => unregisterFileListFilter(filter.id)).not.toThrow() }) @@ -150,7 +151,7 @@ describe('File list filter functions', () => { unregisterFileListFilter(filter.id) expect(spy).toHaveBeenCalled() expect(spy.mock.calls[0][0]).toBeInstanceOf(CustomEvent) - expect(spy.mock.calls[0][0].detail).toBe(filter) + expect(spy.mock.calls[0][0].detail).toBe(filter.id) }) test('can get registered filters', () => { diff --git a/__tests__/headers/listHeaders.spec.ts b/__tests__/headers/listHeaders.spec.ts index dac6bc1e3..e72cbebaa 100644 --- a/__tests__/headers/listHeaders.spec.ts +++ b/__tests__/headers/listHeaders.spec.ts @@ -1,4 +1,4 @@ -/** +/* * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -6,18 +6,19 @@ import type { IFileListHeader, IFolder, IView } from '../../lib/index.ts' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { scopedGlobals } from '../../lib/globalScope.ts' import { getFileListHeaders, registerFileListHeader } from '../../lib/headers/index.ts' import { getRegistry } from '../../lib/registry.ts' import logger from '../../lib/utils/logger.ts' describe('FileListHeader init', () => { beforeEach(() => { - delete window._nc_filelistheader + delete scopedGlobals.fileListHeaders }) test('Getting empty uninitialized FileListHeader', () => { const headers = getFileListHeaders() - expect(window._nc_filelistheader).toBeDefined() + expect(Array.isArray(headers)).toBe(true) expect(headers).toHaveLength(0) }) @@ -29,14 +30,9 @@ describe('FileListHeader init', () => { render: () => {}, updated: () => {}, } - - expect(header.id).toBe('test') - expect(header.order).toBe(1) - expect(header.enabled!({} as IFolder, {} as IView)).toBe(true) - registerFileListHeader(header) - expect(window._nc_filelistheader).toHaveLength(1) + expect(scopedGlobals.fileListHeaders).toHaveLength(1) expect(getFileListHeaders()).toHaveLength(1) expect(getFileListHeaders()[0]).toStrictEqual(header) }) @@ -60,6 +56,25 @@ describe('FileListHeader init', () => { expect(callback.mock.calls[0][0].detail).toBe(header) }) + test('getFileListHeaders() returns array', () => { + expect(getFileListHeaders()).toHaveLength(0) + + const header: IFileListHeader = { + id: 'test', + order: 1, + enabled: () => true, + render: () => {}, + updated: () => {}, + } + + registerFileListHeader(header) + + const headers = getFileListHeaders() + expect(Array.isArray(headers)).toBe(true) + expect(headers).toHaveLength(1) + expect(headers[0]).toStrictEqual(header) + }) + test('Duplicate Header gets rejected', () => { logger.error = vi.fn() const header: IFileListHeader = { diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index 0c04d9ff9..41c4f5b0c 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { NewMenuEntry } from '../lib/newMenu/NewMenu.ts' - import { describe, expect, test } from 'vitest' import { getFileActions, registerFileAction } from '../lib/actions/fileAction.ts' import { @@ -18,7 +16,6 @@ import { Permission, removeNewFileMenuEntry, } from '../lib/index.ts' -import { NewMenu } from '../lib/newMenu/NewMenu.ts' describe('Exports checks', () => { test('formatFileSize', () => { @@ -76,38 +73,3 @@ describe('Exports checks', () => { expect(typeof Node).toBe('function') }) }) - -describe('NewFileMenu methods', () => { - const entry = { - id: 'empty-file', - displayName: 'Create empty file', - templateName: 'New file.txt', - iconSvgInline: '', - handler: () => {}, - } as NewMenuEntry - - test('Init NewFileMenu', () => { - expect(window._nc_newfilemenu).toBeUndefined() - - const menuEntries = getNewFileMenuEntries() - expect(menuEntries).toHaveLength(0) - - expect(window._nc_newfilemenu).toBeDefined() - expect(window._nc_newfilemenu).toBeInstanceOf(NewMenu) - }) - - test('Use existing initialized NewMenu', () => { - expect(window._nc_newfilemenu).toBeDefined() - expect(window._nc_newfilemenu).toBeInstanceOf(NewMenu) - - addNewFileMenuEntry(entry) - - expect(window._nc_newfilemenu).toBeDefined() - expect(window._nc_newfilemenu).toBeInstanceOf(NewMenu) - - removeNewFileMenuEntry(entry) - - expect(window._nc_newfilemenu).toBeDefined() - expect(window._nc_newfilemenu).toBeInstanceOf(NewMenu) - }) -}) diff --git a/__tests__/navigation.spec.ts b/__tests__/navigation.spec.ts index fe356ccdd..9a7986d64 100644 --- a/__tests__/navigation.spec.ts +++ b/__tests__/navigation.spec.ts @@ -1,9 +1,10 @@ -/** +/* * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import { describe, expect, it, vi } from 'vitest' +import { scopedGlobals } from '../lib/globalScope.ts' import { View } from '../lib/index.ts' import { getNavigation, Navigation } from '../lib/navigation/navigation.ts' import { mockView } from './fixtures/view.ts' @@ -18,21 +19,15 @@ describe('getNavigation', () => { }) it('stores the navigation globally', () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - delete window._nc_navigation + delete scopedGlobals.navigation const navigation = getNavigation() expect(navigation).toBeInstanceOf(Navigation) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(window._nc_navigation).toBeInstanceOf(Navigation) + expect(scopedGlobals.navigation).toBeInstanceOf(Navigation) }) it('reuses an existing navigation', () => { const navigation = new Navigation() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window._nc_navigation = navigation + scopedGlobals.navigation = navigation expect(getNavigation()).toBe(navigation) }) }) diff --git a/__tests__/newMenu/newFileMenu.spec.ts b/__tests__/newMenu/newFileMenu.spec.ts index cf6d417f8..c04faf8c6 100644 --- a/__tests__/newMenu/newFileMenu.spec.ts +++ b/__tests__/newMenu/newFileMenu.spec.ts @@ -6,7 +6,8 @@ import type { NewMenuEntry } from '../../lib/newMenu/index.ts' import { afterEach, describe, expect, test, vi } from 'vitest' -import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries } from '../../lib/newMenu/index.ts' +import { scopedGlobals } from '../../lib/globalScope.ts' +import { addNewFileMenuEntry, getNewFileMenu, getNewFileMenuEntries, removeNewFileMenuEntry } from '../../lib/newMenu/index.ts' import { NewMenu, NewMenuEntryCategory } from '../../lib/newMenu/NewMenu.ts' import { Folder } from '../../lib/node/index.ts' import { Permission } from '../../lib/permissions.ts' @@ -14,18 +15,16 @@ import logger from '../../lib/utils/logger.ts' describe('NewFileMenu init', () => { test('Initializing NewFileMenu', () => { - logger.debug = vi.fn() const newFileMenu = getNewFileMenu() - expect(window._nc_newfilemenu).toBeInstanceOf(NewMenu) - expect(window._nc_newfilemenu).toBe(newFileMenu) - expect(logger.debug).toHaveBeenCalled() + expect(scopedGlobals.newFileMenu).toBeInstanceOf(NewMenu) + expect(scopedGlobals.newFileMenu).toBe(newFileMenu) }) test('Getting existing NewMenu', () => { const newFileMenu = new NewMenu() - Object.assign(window, { _nc_newfilemenu: newFileMenu }) + Object.assign(scopedGlobals, { newFileMenu }) - expect(window._nc_newfilemenu).toBe(newFileMenu) + expect(scopedGlobals.newFileMenu).toBe(newFileMenu) expect(getNewFileMenu()).toBe(newFileMenu) }) }) @@ -399,7 +398,7 @@ describe('NewMenu getEntries filter', () => { describe('NewMenu sort test', () => { afterEach(() => { - delete window._nc_newfilemenu + delete scopedGlobals.newFileMenu }) test('Specified NewMenu order', () => { @@ -490,3 +489,38 @@ describe('NewMenu sort test', () => { expect(entries[3]).toBe(entry2) }) }) + +describe('NewFileMenu methods', () => { + const entry = { + id: 'empty-file', + displayName: 'Create empty file', + templateName: 'New file.txt', + iconSvgInline: '', + handler: () => {}, + } as NewMenuEntry + + test('Init NewFileMenu', () => { + expect(scopedGlobals.newFileMenu).toBeUndefined() + + const menuEntries = getNewFileMenuEntries() + expect(menuEntries).toHaveLength(0) + + expect(scopedGlobals.newFileMenu).toBeDefined() + expect(scopedGlobals.newFileMenu).toBeInstanceOf(NewMenu) + }) + + test('Use existing initialized NewMenu', () => { + expect(scopedGlobals.newFileMenu).toBeDefined() + expect(scopedGlobals.newFileMenu).toBeInstanceOf(NewMenu) + + addNewFileMenuEntry(entry) + + expect(scopedGlobals.newFileMenu).toBeDefined() + expect(scopedGlobals.newFileMenu).toBeInstanceOf(NewMenu) + + removeNewFileMenuEntry(entry) + + expect(scopedGlobals.newFileMenu).toBeDefined() + expect(scopedGlobals.newFileMenu).toBeInstanceOf(NewMenu) + }) +}) diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts index 4f84de36d..c4efd4ad5 100644 --- a/__tests__/sidebar/sidebarTab.spec.ts +++ b/__tests__/sidebar/sidebarTab.spec.ts @@ -6,6 +6,7 @@ import type { ISidebarTab } from '../../lib/sidebar/SidebarTab.ts' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { scopedGlobals } from '../../lib/globalScope.ts' import { getSidebarTabs, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts' // missing in JSDom but supported by every browser! @@ -14,16 +15,16 @@ import 'css.escape' describe('Sidebar tabs', () => { beforeEach(() => { vi.restoreAllMocks() - delete window._nc_files_sidebar_tabs + delete scopedGlobals.filesSidebarTabs }) it('can register a tab', () => { const tab = getExampleTab() registerSidebarTab(tab) - expect(window._nc_files_sidebar_tabs).toBeInstanceOf(Map) - expect(window._nc_files_sidebar_tabs!.has(tab.id)).toBe(true) - expect(window._nc_files_sidebar_tabs!.get(tab.id)).toBe(tab) + expect(scopedGlobals.filesSidebarTabs).toBeInstanceOf(Map) + expect(scopedGlobals.filesSidebarTabs!.has(tab.id)).toBe(true) + expect(scopedGlobals.filesSidebarTabs!.get(tab.id)).toBe(tab) }) it('can fetch empty list of sidebar tabs', () => { diff --git a/lib/actions/fileAction.ts b/lib/actions/fileAction.ts index 7771bf80e..8b9753322 100644 --- a/lib/actions/fileAction.ts +++ b/lib/actions/fileAction.ts @@ -5,6 +5,7 @@ import type { ActionContext, ActionContextSingle } from '../types.ts' +import { scopedGlobals } from '../globalScope.ts' import { getRegistry } from '../registry.ts' import logger from '../utils/logger.ts' @@ -128,13 +129,13 @@ export interface IFileAction { export function registerFileAction(action: IFileAction): void { validateAction(action) - window._nc_fileactions ??= [] - if (window._nc_fileactions.find((search) => search.id === action.id)) { + scopedGlobals.fileActions ??= new Map() + if (scopedGlobals.fileActions.has(action.id)) { logger.error(`FileAction ${action.id} already registered`, { action }) return } - window._nc_fileactions.push(action) + scopedGlobals.fileActions.set(action.id, action) getRegistry() .dispatchTypedEvent('register:action', new CustomEvent('register:action', { detail: action })) } @@ -143,7 +144,10 @@ export function registerFileAction(action: IFileAction): void { * Get all registered file actions. */ export function getFileActions(): IFileAction[] { - return window._nc_fileactions ?? [] + if (scopedGlobals.fileActions) { + return [...scopedGlobals.fileActions.values()] + } + return [] } /** diff --git a/lib/actions/fileListAction.ts b/lib/actions/fileListAction.ts index 247cbba65..8cfff0a32 100644 --- a/lib/actions/fileListAction.ts +++ b/lib/actions/fileListAction.ts @@ -5,6 +5,7 @@ import type { ViewActionContext } from '../types.ts' +import { scopedGlobals } from '../globalScope.ts' import { getRegistry } from '../registry.ts' import logger from '../utils/logger.ts' @@ -44,13 +45,13 @@ export interface IFileListAction { export function registerFileListAction(action: IFileListAction) { validateAction(action) - window._nc_filelistactions ??= [] - if (window._nc_filelistactions.find((listAction) => listAction.id === action.id)) { + scopedGlobals.fileListActions ??= new Map() + if (scopedGlobals.fileListActions.has(action.id)) { logger.error(`FileListAction with id "${action.id}" is already registered`, { action }) return } - window._nc_filelistactions.push(action) + scopedGlobals.fileListActions.set(action.id, action) getRegistry() .dispatchTypedEvent('register:listAction', new CustomEvent('register:listAction', { detail: action })) } @@ -59,7 +60,10 @@ export function registerFileListAction(action: IFileListAction) { * Get all currently registered file list actions. */ export function getFileListActions(): IFileListAction[] { - return [...(window._nc_filelistactions ?? [])] + if (scopedGlobals.fileListActions) { + return [...scopedGlobals.fileListActions.values()] + } + return [] } /** diff --git a/lib/dav/davProperties.ts b/lib/dav/davProperties.ts index 3434468da..cda5d34a4 100644 --- a/lib/dav/davProperties.ts +++ b/lib/dav/davProperties.ts @@ -4,6 +4,7 @@ */ import { getCurrentUser } from '@nextcloud/auth' +import { scopedGlobals } from '../globalScope.ts' import logger from '../utils/logger.ts' export type DavProperty = { [key: string]: string } @@ -45,15 +46,13 @@ export const defaultDavNamespaces = { * @param namespace The namespace of the property */ export function registerDavProperty(prop: string, namespace: DavProperty = { nc: 'http://nextcloud.org/ns' }): boolean { - if (typeof window._nc_dav_properties === 'undefined') { - window._nc_dav_properties = [...defaultDavProperties] - window._nc_dav_namespaces = { ...defaultDavNamespaces } - } + scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces } + scopedGlobals.davProperties ??= [...defaultDavProperties] - const namespaces = { ...window._nc_dav_namespaces, ...namespace } + const namespaces = { ...scopedGlobals.davNamespaces, ...namespace } // Check duplicates - if (window._nc_dav_properties.find((search) => search === prop)) { + if (scopedGlobals.davProperties.find((search) => search === prop)) { logger.warn(`${prop} already registered`, { prop }) return false } @@ -69,8 +68,8 @@ export function registerDavProperty(prop: string, namespace: DavProperty = { nc: return false } - window._nc_dav_properties.push(prop) - window._nc_dav_namespaces = namespaces + scopedGlobals.davProperties.push(prop) + scopedGlobals.davNamespaces = namespaces return true } @@ -78,23 +77,17 @@ export function registerDavProperty(prop: string, namespace: DavProperty = { nc: * Get the registered dav properties */ export function getDavProperties(): string { - if (typeof window._nc_dav_properties === 'undefined') { - window._nc_dav_properties = [...defaultDavProperties] - } - - return window._nc_dav_properties.map((prop) => `<${prop} />`).join(' ') + scopedGlobals.davProperties ??= [...defaultDavProperties] + return scopedGlobals.davProperties.map((prop) => `<${prop} />`).join(' ') } /** * Get the registered dav namespaces */ export function getDavNameSpaces(): string { - if (typeof window._nc_dav_namespaces === 'undefined') { - window._nc_dav_namespaces = { ...defaultDavNamespaces } - } - - return Object.keys(window._nc_dav_namespaces) - .map((ns) => `xmlns:${ns}="${window._nc_dav_namespaces?.[ns]}"`) + scopedGlobals.davNamespaces ??= { ...defaultDavNamespaces } + return Object.keys(scopedGlobals.davNamespaces) + .map((ns) => `xmlns:${ns}="${scopedGlobals.davNamespaces?.[ns]}"`) .join(' ') } diff --git a/lib/filters/functions.ts b/lib/filters/functions.ts index b5ee56c78..853907482 100644 --- a/lib/filters/functions.ts +++ b/lib/filters/functions.ts @@ -5,6 +5,7 @@ import type { IFileListFilter } from './listFilters.ts' +import { scopedGlobals } from '../globalScope.ts' import { getRegistry } from '../registry.ts' /** @@ -16,12 +17,12 @@ import { getRegistry } from '../registry.ts' * @param filter The filter to register on the file list */ export function registerFileListFilter(filter: IFileListFilter): void { - window._nc_filelist_filters ??= new Map() - if (window._nc_filelist_filters.has(filter.id)) { + scopedGlobals.fileListFilters ??= new Map() + if (scopedGlobals.fileListFilters.has(filter.id)) { throw new Error(`File list filter "${filter.id}" already registered`) } - window._nc_filelist_filters.set(filter.id, filter) + scopedGlobals.fileListFilters.set(filter.id, filter) getRegistry() .dispatchTypedEvent('register:listFilter', new CustomEvent('register:listFilter', { detail: filter })) } @@ -32,11 +33,10 @@ export function registerFileListFilter(filter: IFileListFilter): void { * @param filterId The unique ID of the filter to remove */ export function unregisterFileListFilter(filterId: string): void { - if (window._nc_filelist_filters && window._nc_filelist_filters.has(filterId)) { - const filter = window._nc_filelist_filters.get(filterId)! - window._nc_filelist_filters.delete(filterId) + if (scopedGlobals.fileListFilters && scopedGlobals.fileListFilters.has(filterId)) { + scopedGlobals.fileListFilters.delete(filterId) getRegistry() - .dispatchTypedEvent('unregister:listFilter', new CustomEvent('unregister:listFilter', { detail: filter })) + .dispatchTypedEvent('unregister:listFilter', new CustomEvent('unregister:listFilter', { detail: filterId })) } } @@ -44,8 +44,8 @@ export function unregisterFileListFilter(filterId: string): void { * Get all registered file list filters */ export function getFileListFilters(): IFileListFilter[] { - if (!window._nc_filelist_filters) { - return [] + if (scopedGlobals.fileListFilters) { + return [...scopedGlobals.fileListFilters.values()] } - return [...window._nc_filelist_filters.values()] + return [] } diff --git a/lib/globalScope.ts b/lib/globalScope.ts new file mode 100644 index 000000000..9e26f7e08 --- /dev/null +++ b/lib/globalScope.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFileAction, IFileListAction } from './actions/index.ts' +import type { DavProperty } from './dav/index.ts' +import type { + IFileListFilter, + IFileListHeader, + Navigation, + NewMenu, +} from './index.ts' +import type { FilesRegistry } from './registry.ts' +import type { ISidebarTab } from './sidebar/index.ts' +import type { ISidebarAction } from './sidebar/SidebarAction.ts' + +interface InternalGlobalScope { + davNamespaces?: DavProperty + davProperties?: string[] + + newFileMenu?: NewMenu + navigation?: Navigation + registry?: FilesRegistry + + fileActions?: Map + fileListActions?: Map + fileListFilters?: Map + fileListHeaders?: Map + + filesSidebarActions?: Map + filesSidebarTabs?: Map +} + +window._nc_files_scope ??= {} +window._nc_files_scope.v4_0 ??= {} + +/** + * Get the global scope for the files library. + * This is used to store global variables scoped to prevent breaking changes in the future. + * + * @internal + */ +export const scopedGlobals = window._nc_files_scope.v4_0 as InternalGlobalScope diff --git a/lib/headers/listHeaders.ts b/lib/headers/listHeaders.ts index 3d38a5d25..7727ce12a 100644 --- a/lib/headers/listHeaders.ts +++ b/lib/headers/listHeaders.ts @@ -6,6 +6,7 @@ import type { IView } from '../navigation/view.ts' import type { IFolder } from '../node/folder.ts' +import { scopedGlobals } from '../globalScope.ts' import { getRegistry } from '../registry.ts' import logger from '../utils/logger.ts' @@ -30,13 +31,13 @@ export interface IFileListHeader { export function registerFileListHeader(header: IFileListHeader): void { validateHeader(header) - window._nc_filelistheader ??= [] - if (window._nc_filelistheader.find((search) => search.id === header.id)) { + scopedGlobals.fileListHeaders ??= new Map() + if (scopedGlobals.fileListHeaders.has(header.id)) { logger.error(`Header ${header.id} already registered`, { header }) return } - window._nc_filelistheader.push(header) + scopedGlobals.fileListHeaders.set(header.id, header) getRegistry() .dispatchTypedEvent('register:listHeader', new CustomEvent('register:listHeader', { detail: header })) } @@ -45,8 +46,10 @@ export function registerFileListHeader(header: IFileListHeader): void { * Get all currently registered file list headers. */ export function getFileListHeaders(): IFileListHeader[] { - window._nc_filelistheader ??= [] - return [...window._nc_filelistheader] + if (!scopedGlobals.fileListHeaders) { + return [] + } + return [...scopedGlobals.fileListHeaders.values()] } /** diff --git a/lib/navigation/navigation.ts b/lib/navigation/navigation.ts index 9a9c3912b..c6cf05117 100644 --- a/lib/navigation/navigation.ts +++ b/lib/navigation/navigation.ts @@ -6,7 +6,7 @@ import type { IView } from './view.ts' import { TypedEventTarget } from 'typescript-event-target' -import logger from '../utils/logger.ts' +import { scopedGlobals } from '../globalScope.ts' import { validateView } from './view.ts' /** @@ -123,10 +123,6 @@ export class Navigation extends TypedEventTarget<{ updateActive: UpdateActiveVie * Get the current files navigation */ export function getNavigation(): Navigation { - if (typeof window._nc_navigation === 'undefined') { - window._nc_navigation = new Navigation() - logger.debug('Navigation service initialized') - } - - return window._nc_navigation + scopedGlobals.navigation ??= new Navigation() + return scopedGlobals.navigation } diff --git a/lib/newMenu/functions.ts b/lib/newMenu/functions.ts index 2a7077f2e..1ab5a9936 100644 --- a/lib/newMenu/functions.ts +++ b/lib/newMenu/functions.ts @@ -6,18 +6,15 @@ import type { IFolder } from '../node/index.ts' import type { NewMenuEntry } from './NewMenu.ts' -import logger from '../utils/logger.ts' +import { scopedGlobals } from '../globalScope.ts' import { NewMenu } from './NewMenu.ts' /** * Get the NewMenu instance used by the files app. */ export function getNewFileMenu(): NewMenu { - if (typeof window._nc_newfilemenu === 'undefined') { - window._nc_newfilemenu = new NewMenu() - logger.debug('NewFileMenu initialized') - } - return window._nc_newfilemenu + scopedGlobals.newFileMenu ??= new NewMenu() + return scopedGlobals.newFileMenu } /** diff --git a/lib/registry.ts b/lib/registry.ts index deb513020..984aa8afb 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -8,31 +8,59 @@ import type { IFileListFilter } from './filters/index.ts' import type { IFileListHeader } from './headers/index.ts' import { TypedEventTarget } from 'typescript-event-target' +import { scopedGlobals } from './globalScope.ts' interface FilesRegistryEvents { - 'register:action': CustomEvent - 'register:listAction': CustomEvent - 'register:listFilter': CustomEvent - 'unregister:listFilter': CustomEvent - 'register:listHeader': CustomEvent + 'register:action': RegistrationEvent + 'register:listAction': RegistrationEvent + 'register:listFilter': RegistrationEvent + 'register:listHeader': RegistrationEvent + 'unregister:listFilter': UnregisterEvent } -export class FilesRegistryV4 extends TypedEventTarget {} +/** + * Custom event for registry events. + * The detail is the registered item. + */ +class RegistrationEvent extends CustomEvent {} -export type PublicFilesRegistry = Pick +/** + * Custom event for unregistering items from the registry. + * The detail is the id of the unregistered item. + */ +class UnregisterEvent extends RegistrationEvent {} + +/** + * The registry for files app. + * This is used to keep track of registered actions, filters, headers, etc. and to emit events when new items are registered. + * Allowing to keep a reactive state of the registered items in the UI without being coupled to one specific reactivity framework. + * + * This is an internal implementation detail and should not be used directly. + * + * @internal + * @see PublicFilesRegistry - for the public API to listen to registry events. + */ +export class FilesRegistry extends TypedEventTarget {} + +/** + * The registry for files app. + * This is used to keep track of registered actions, filters, headers, etc. and to emit events when new items are registered. + * Allowing to keep a reactive state of the registered items in the UI without being coupled to one specific reactivity framework. + */ +export type PublicFilesRegistry = Pick /** - * Get the global files registry + * Get the global files registry. * * @internal */ export function getRegistry() { - window._nc_files_registry_v4 ??= new FilesRegistryV4() - return window._nc_files_registry_v4 + scopedGlobals.registry ??= new FilesRegistry() + return scopedGlobals.registry } /** - * Get the global files registry + * Get the global files registry. * * This allows to listen for new registrations of actions, filters, headers, etc. * Events are dispatched by the respective registration functions. diff --git a/lib/sidebar/SidebarAction.ts b/lib/sidebar/SidebarAction.ts index 2b29c523f..65fe9860e 100644 --- a/lib/sidebar/SidebarAction.ts +++ b/lib/sidebar/SidebarAction.ts @@ -5,6 +5,7 @@ import type { ISidebarContext } from './SidebarTab.ts' +import { scopedGlobals } from '../globalScope.ts' import logger from '../utils/logger.ts' /** @@ -61,12 +62,12 @@ export interface ISidebarAction { export function registerSidebarAction(action: ISidebarAction): void { validateSidebarAction(action) - window._nc_files_sidebar_actions ??= new Map() - if (window._nc_files_sidebar_actions.has(action.id)) { + scopedGlobals.filesSidebarActions ??= new Map() + if (scopedGlobals.filesSidebarActions.has(action.id)) { logger.warn(`Sidebar action with id "${action.id}" already registered. Skipping.`) return } - window._nc_files_sidebar_actions.set(action.id, action) + scopedGlobals.filesSidebarActions.set(action.id, action) logger.debug(`New sidebar action with id "${action.id}" registered.`) } @@ -74,8 +75,8 @@ export function registerSidebarAction(action: ISidebarAction): void { * Get all currently registered sidebar actions. */ export function getSidebarActions(): ISidebarAction[] { - if (window._nc_files_sidebar_actions) { - return [...window._nc_files_sidebar_actions.values()] + if (scopedGlobals.filesSidebarActions) { + return [...scopedGlobals.filesSidebarActions.values()] } return [] } diff --git a/lib/sidebar/SidebarTab.ts b/lib/sidebar/SidebarTab.ts index d1303b19e..f7f313c7c 100644 --- a/lib/sidebar/SidebarTab.ts +++ b/lib/sidebar/SidebarTab.ts @@ -7,6 +7,7 @@ import type { IView } from '../navigation/view.ts' import type { IFolder, INode } from '../node/index.ts' import isSvg from 'is-svg' +import { scopedGlobals } from '../globalScope.ts' import logger from '../utils/logger.ts' export interface ISidebarContext { @@ -111,12 +112,12 @@ export interface ISidebarTab { export function registerSidebarTab(tab: ISidebarTab): void { validateSidebarTab(tab) - window._nc_files_sidebar_tabs ??= new Map() - if (window._nc_files_sidebar_tabs.has(tab.id)) { + scopedGlobals.filesSidebarTabs ??= new Map() + if (scopedGlobals.filesSidebarTabs.has(tab.id)) { logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`) return } - window._nc_files_sidebar_tabs.set(tab.id, tab) + scopedGlobals.filesSidebarTabs.set(tab.id, tab) logger.debug(`New sidebar tab with id "${tab.id}" registered.`) } @@ -124,8 +125,8 @@ export function registerSidebarTab(tab: ISidebarTab): void { * Get all currently registered sidebar tabs. */ export function getSidebarTabs(): ISidebarTab[] { - if (window._nc_files_sidebar_tabs) { - return [...window._nc_files_sidebar_tabs.values()] + if (scopedGlobals.filesSidebarTabs) { + return [...scopedGlobals.filesSidebarTabs.values()] } return [] } diff --git a/lib/window.d.ts b/lib/window.d.ts index f953a2892..519145e35 100644 --- a/lib/window.d.ts +++ b/lib/window.d.ts @@ -4,40 +4,16 @@ */ /// -import type { IFileAction, IFileListAction } from './actions/index.ts' -import type { DavProperty } from './dav/index.ts' -import type { - IFileListFilter, - IFileListHeader, - Navigation, - NewMenu, -} from './index.ts' -import type { FilesRegistryV4 } from './registry.ts' -import type { ISidebarTab } from './sidebar/index.ts' -import type { ISidebarAction } from './sidebar/SidebarAction.ts' - -export {} - declare global { interface Window { OC: Nextcloud.v32.OC // eslint-disable-next-line @typescript-eslint/no-explicit-any OCA: any - _nc_dav_namespaces?: DavProperty - _nc_dav_properties?: string[] - _nc_fileactions?: IFileAction[] - _nc_filelistactions?: IFileListAction[] - _nc_filelistheader?: IFileListHeader[] - _nc_newfilemenu?: NewMenu - _nc_navigation?: Navigation - _nc_filelist_filters?: Map - _nc_files_sidebar_actions?: Map - _nc_files_sidebar_tabs?: Map - - _nc_files_registry_v4?: FilesRegistryV4 - + _nc_files_scope?: Record> _oc_config?: { forbidden_filenames_characters: string[] } } } + +export {}