diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts
new file mode 100644
index 000000000..4a1dd461e
--- /dev/null
+++ b/__tests__/sidebar/sidebarTab.spec.ts
@@ -0,0 +1,161 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
+
+// missing in JSDom but supported by every browser!
+import 'css.escape'
+
+describe('Sidebar tabs', () => {
+ beforeEach(() => {
+ delete window._nc_files_sidebar_tabs
+ })
+
+ 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)
+ })
+
+ it('can fetch empty list of sidebar tabs', () => {
+ expect(getSidebarTabs()).toBeInstanceOf(Array)
+ expect(getSidebarTabs()).toHaveLength(0)
+ })
+
+ it('can fetch list of sidebar tabs', () => {
+ registerSidebarTab(getExampleTab())
+ registerSidebarTab({ ...getExampleTab(), id: 'another-example' })
+
+ expect(getSidebarTabs()).toBeInstanceOf(Array)
+ expect(getSidebarTabs()).toHaveLength(2)
+ })
+
+ it('only registeres same id once', () => {
+ const consoleSpy = vi.spyOn(console, 'warn')
+ consoleSpy.mockImplementationOnce(() => {})
+
+ registerSidebarTab(getExampleTab())
+ registerSidebarTab(getExampleTab())
+ expect(consoleSpy).toHaveBeenCalledOnce()
+ expect(getSidebarTabs()).toHaveLength(1)
+ })
+
+ describe('Tab validation', () => {
+ it('fails with an invalid parameter', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab(getExampleTab),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is not an object]')
+ })
+
+ it('fails with missing id', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), id: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
+ })
+
+ it('fails with non conforming id', () => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), id: 'this is invalid' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an id conforming to the HTML id attribute specifications]')
+ })
+
+ it('fails with missing name', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), displayName: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
+ })
+
+ it('fails with invalid name', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), displayName: 1234 }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a name set]')
+ })
+
+ it('fails with missing icon', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), iconSvg: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
+ })
+
+ it('fails with invalid SVG icon', () => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), iconSvg: 'icon-group' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an valid SVG icon]')
+ })
+
+ it('fails with missing order', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), order: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
+ })
+
+ it('fails with invalid order', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), order: '3' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a numeric order set]')
+ })
+
+ it('fails with missing "enabled" method', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), enabled: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have an "enabled" method]')
+ })
+
+ it('fails with missing "setActive" method', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), setActive: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tabs need to have a "setActive" method]')
+ })
+
+ it.for(['mount', 'unmount', 'update'])('fails with missing lifecylce methods', (method) => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), [method]: undefined }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: Sidebar tab is missing a required lifecycle method]')
+ })
+
+ it('works without specifying a scroll listener', () => {
+ expect(
+ () => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: undefined }),
+ ).not.toThrow()
+ })
+
+ it('fails with an invalid scroll listener', () => {
+ expect(
+ // @ts-expect-error mocking for testing
+ () => registerSidebarTab({ ...getExampleTab(), onScrollBottomReached: 'not a method' }),
+ ).toThrowErrorMatchingInlineSnapshot('[Error: "onScrollBottomReached" of the sidebar tab needs to be a function]')
+ })
+ })
+})
+
+/**
+ * Get a very basic mock of a sidebar tab
+ */
+function getExampleTab(): ISidebarTab {
+ return {
+ id: 'example-tab',
+ displayName: 'Example',
+ enabled: vi.fn(),
+ iconSvg: '',
+ order: 0,
+ mount: vi.fn(),
+ unmount: vi.fn(),
+ update: vi.fn(),
+ setActive: vi.fn(),
+ onScrollBottomReached: vi.fn(),
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index cd16c35d9..3f97ec971 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -10,6 +10,7 @@ export * from './navigation/index.ts'
export * from './newMenu/index.ts'
export * from './node/index.ts'
export * from './permissions.ts'
+export * from './sidebar/index.ts'
export * from './utils/index.ts'
// Legacy export of dav utils
diff --git a/lib/sidebar/index.ts b/lib/sidebar/index.ts
new file mode 100644
index 000000000..3ca7d2bf1
--- /dev/null
+++ b/lib/sidebar/index.ts
@@ -0,0 +1,165 @@
+/*
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFolder, INode } from '../node/index.ts'
+
+import isSvg from 'is-svg'
+import logger from '../utils/logger.ts'
+
+interface SidebarUpdateContext {
+ /**
+ * The currently selected node for which the sidebar is shown.
+ */
+ node: INode
+
+ /**
+ * The parent of the current selected node.
+ * This is not necessarily the real parent folder of the node in means of the real filesystem tree,
+ * but rather the parent folder in the current view of the files app.
+ */
+ parent: IFolder
+}
+
+/**
+ * Implementation of a custom sidebar tab within the files app.
+ */
+export interface ISidebarTab {
+ /**
+ * Unique id of the sidebar tab.
+ * This has to conform to the HTML id attribute specification.
+ */
+ id: string
+
+ /**
+ * The localized name of the sidebar tab.
+ */
+ displayName: string
+
+ /**
+ * The icon, as SVG, of the sidebar tab.
+ */
+ iconSvg: string
+
+ /**
+ * The order of this tab.
+ * Use a low number to make this tab ordered in front.
+ */
+ order: number
+
+ /**
+ * Callback to check if the sidebar tab should be shown for the selected node.
+ *
+ * @param node - The currently selected node
+ */
+ enabled: (node: INode) => boolean
+
+ /**
+ * Called by the files app if this tab has become the active tab or was deactivated.
+ *
+ * @param active - The new active state of this tab
+ */
+ setActive: (active: boolean) => void | Promise
+
+ /**
+ * The lifecylce method for mounting the sidebar tab onto the sidebar.
+ *
+ * @param el - The element to mount the sidebar tab to
+ * @param context - The current sidebar context
+ */
+ mount: (el: HTMLElement, context: SidebarUpdateContext) => void | Promise
+
+ /**
+ * The lifecycle method for updating the sidebar tab.
+ * This is called if the currently selected node changes.
+ *
+ * @param context - The current sidebar context
+ */
+ update: (context: SidebarUpdateContext) => void | Promise
+
+ /**
+ * The lifecycle method for unmounting the sidebar tab.
+ * This is called if the sidebar is unmounted from the files app and thus the sidebar tab needs to do its cleanup and unmounting.
+ */
+ unmount: () => void | Promise
+
+ /**
+ * Called when the bottom of the sidebar was reached during scrolling.
+ */
+ onScrollBottomReached?: () => void | Promise
+}
+
+/**
+ * Register a new sidebar tab for the files app.
+ *
+ * @param tab - The sidebar tab to register
+ * @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
+ */
+export function registerSidebarTab(tab: ISidebarTab): void {
+ validateSidebarTab(tab)
+
+ window._nc_files_sidebar_tabs ??= new Map()
+ if (window._nc_files_sidebar_tabs.has(tab.id)) {
+ logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
+ return
+ }
+ window._nc_files_sidebar_tabs.set(tab.id, tab)
+ logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
+}
+
+/**
+ * Get all currently registered sidebar tabs.
+ */
+export function getSidebarTabs(): ISidebarTab[] {
+ if (window._nc_files_sidebar_tabs) {
+ return [...window._nc_files_sidebar_tabs.values()]
+ }
+ return []
+}
+
+/**
+ * Check if a given sidebar tab objects implements all necessary fields.
+ *
+ * @param tab - The sidebar tab to validate
+ */
+function validateSidebarTab(tab: ISidebarTab): void {
+ if (typeof tab !== 'object') {
+ throw new Error('Sidebar tab is not an object')
+ }
+
+ if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
+ throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
+ }
+
+ if (!tab.displayName || typeof tab.displayName !== 'string') {
+ throw new Error('Sidebar tabs need to have a name set')
+ }
+
+ if (typeof tab.iconSvg !== 'string' || !isSvg(tab.iconSvg)) {
+ throw new Error('Sidebar tabs need to have an valid SVG icon')
+ }
+
+ if (typeof tab.order !== 'number') {
+ throw new Error('Sidebar tabs need to have a numeric order set')
+ }
+
+ if (typeof tab.enabled !== 'function') {
+ throw new Error('Sidebar tabs need to have an "enabled" method')
+ }
+
+ if (typeof tab.setActive !== 'function') {
+ throw new Error('Sidebar tabs need to have a "setActive" method')
+ }
+
+ if (typeof tab.mount !== 'function'
+ || typeof tab.update !== 'function'
+ || typeof tab.unmount !== 'function'
+ ) {
+ throw new Error('Sidebar tab is missing a required lifecycle method')
+ }
+
+ if (tab.onScrollBottomReached && typeof tab.onScrollBottomReached !== 'function') {
+ throw new Error('"onScrollBottomReached" of the sidebar tab needs to be a function')
+ }
+}
diff --git a/lib/window.d.ts b/lib/window.d.ts
index 0af2eb4ea..fbd357f05 100644
--- a/lib/window.d.ts
+++ b/lib/window.d.ts
@@ -13,9 +13,8 @@ import type {
NewMenu,
} from './index.ts'
-import type {
- DavProperty,
-} from './dav/index.ts'
+import type { DavProperty } from './dav/index.ts'
+import type { ISidebarTab } from './sidebar/index.ts'
export {}
@@ -30,6 +29,7 @@ declare global {
_nc_newfilemenu?: NewMenu
_nc_navigation?: Navigation
_nc_filelist_filters?: Map
+ _nc_files_sidebar_tabs?: Map
_oc_config?: {
forbidden_filenames_characters: string[]
diff --git a/package-lock.json b/package-lock.json
index aa6719a16..2a6a2053b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@nextcloud/vite-config": "^2.4.0",
"@types/node": "^24.2.1",
"@vitest/coverage-istanbul": "^3.2.4",
+ "css.escape": "^1.5.1",
"fast-xml-parser": "^5.2.5",
"jsdom": "^26.1.0",
"tslib": "^2.8.1",
@@ -4406,6 +4407,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
diff --git a/package.json b/package.json
index a2c370203..eacd024eb 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"@nextcloud/vite-config": "^2.4.0",
"@types/node": "^24.2.1",
"@vitest/coverage-istanbul": "^3.2.4",
+ "css.escape": "^1.5.1",
"fast-xml-parser": "^5.2.5",
"jsdom": "^26.1.0",
"tslib": "^2.8.1",