Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __tests__/sidebar/sidebarTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { IFolder, INode, IView } from '../../lib/index.ts'

import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar'
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts'
// missing in JSDom but supported by every browser!
import 'css.escape'

Expand Down
92 changes: 92 additions & 0 deletions lib/sidebar/Sidebar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts'

import { getSidebarTabs, registerSidebarTab } from './SidebarTab.ts'

export interface ISidebar {
/**
* If the files sidebar can currently be accessed.
* Registering tabs also works if the sidebar is currently not available.
*/
readonly available: boolean

/**
* The current open state of the sidebar
*/
readonly open: boolean

/**
* Open or close the sidebar
*
* @param open - The new open state
*/
setOpen(open: boolean): void

/**
* Register a new sidebar tab
*
* @param tab - The sidebar tab to register
*/
registerTab(tab: ISidebarTab): void

/**
* Get all registered sidebar tabs.
* If a node is passed only the enabled tabs are retrieved.
*/
getTabs(context?: ISidebarContext): ISidebarTab[]
}

/**
* This is just a proxy allowing an arbitrary `@nextcloud/files` library version to access the defined interface of the sidebar.
* By proxying this instead of providing the implementation here we ensure that if apps use different versions of the library,
* we do not end up with version conflicts between them.
*
* If we add new properties they just will be available in new library versions.
* If we decide to do a breaking change we can either add compatibility wrappers in the implementation in the files app.
*/
class SidebarProxy implements ISidebar {

get available(): boolean {
return !!window.OCA?.Files?.Sidebar
}

get open(): boolean {
return !!window.OCA?.Files?.Sidebar?.state.file
}

setOpen(open: boolean): void {
if (open) {
window.OCA?.Files?.Sidebar?.open()
} else {
window.OCA?.Files?.Sidebar?.close()
}
}

setActiveTab(tabId: string): void {
window.OCA?.Files?.Sidebar?.setActiveTab(tabId)
}

registerTab(tab: ISidebarTab): void {
registerSidebarTab(tab)
}

getTabs(context?: ISidebarContext): ISidebarTab[] {
const tabs = getSidebarTabs()
if (context) {
return tabs.filter((tab) => tab.enabled(context))
}
return tabs
}

}

/**
* Get a reference to the files sidebar.
*/
export function getSidebar(): ISidebar {
return new SidebarProxy()
}
163 changes: 163 additions & 0 deletions lib/sidebar/SidebarTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IView } from '../navigation/view.ts'
import type { IFolder, INode } from '../node/index.ts'

import isSvg from 'is-svg'
import logger from '../utils/logger.ts'

export interface ISidebarContext {
/**
* The active node in the sidebar
*/
node: INode

/**
* The current open folder in the files app
*/
folder: IFolder

/**
* The currently active view
*/
view: IView
}

/**
* This component describes the custom web component that should be registered for a sidebar tab.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
* @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
*/
export interface SidebarComponent extends HTMLElement, ISidebarContext {
/**
* This method is called by the files app if the sidebar tab state changes.
*
* @param active - The new active state
*/
setActive(active: boolean): Promise<void>
}

/**
* 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.
*/
iconSvgInline: string

/**
* The order of this tab.
* Use a low number to make this tab ordered in front.
*/
order: number

/**
* The tag name of the web component.
* The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
*
* To avoid name clashes the name has to start with your appid (e.g. `your_app`).
* So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
*/
tagName: string

/**
* Callback to check if the sidebar tab should be shown for the selected node.
*
* @param context - The current context of the files app
*/
enabled: (context: ISidebarContext) => boolean
}

/**
* 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<string, ISidebarTab>()
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.tagName || typeof tab.tagName !== 'string') {
throw new Error('Sidebar tabs need to have the tagName name set')
}

if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
throw new Error('Sidebar tabs tagName name is invalid')
}

if (!tab.displayName || typeof tab.displayName !== 'string') {
throw new Error('Sidebar tabs need to have a name set')
}

if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
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')
}

// now check the custom element constructor
const tagConstructor = window.customElements.get(tab.tagName)
if (!tagConstructor) {
throw new Error('Sidebar tab element not registered')
}

if (!('setActive' in tagConstructor.prototype)) {
// we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
throw new Error('Sidebar tab elements must have the `setActive` method')
}
}
Loading