Skip to content

Commit c10176b

Browse files
committed
feat(sidebar): provide a proxy for the files sidebar
Additionally to registering sidebar tabs this also provides a public interface to access the sidebar itself. Which can be used to retrieve or set the currently active tab, allows to open or close it and getting the open state. We can then use this proxy to later add fallbacks for different Nextcloud versions once we modernized the files sidebar, so apps can stick with this interface regardless of the Nextcloud version and implementation used underneath. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent af703f9 commit c10176b

4 files changed

Lines changed: 259 additions & 159 deletions

File tree

lib/sidebar/Sidebar.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { INode } from '../node/index.ts'
7+
import { getSidebarTabs, ISidebarTab, registerSidebarTab } from './SidebarTab.ts'
8+
9+
export interface ISidebar {
10+
/**
11+
* If the files sidebar can currently be accessed.
12+
* Registering tabs also works if the sidebar is currently not available.
13+
*/
14+
readonly available: boolean
15+
16+
/**
17+
* The current open state of the sidebar
18+
*/
19+
readonly open: boolean
20+
21+
/**
22+
* Open or close the sidebar
23+
*
24+
* @param open - The new open state
25+
*/
26+
setOpen(open: boolean): void
27+
28+
/**
29+
* Register a new sidebar tab
30+
*
31+
* @param tab - The sidebar tab to register
32+
*/
33+
registerTab(tab: ISidebarTab): void
34+
35+
/**
36+
* Get all registered sidebar tabs.
37+
* If a node is passed only the enabled tabs are retrieved.
38+
*/
39+
getTabs(node?: INode): ISidebarTab[]
40+
}
41+
42+
/**
43+
* This is just a proxy allowing an arbitrary `@nextcloud/files` library version to access the defined interface of the sidebar.
44+
* By proxying this instead of providing the implementation here we ensure that if apps use different versions of the library,
45+
* we do not end up with version conflicts between them.
46+
*
47+
* If we add new properties they just will be available in new library versions.
48+
* If we decide to do a breaking change we can either add compatibility wrappers in the implementation in the files app.
49+
*/
50+
class SidebarProxy implements ISidebar {
51+
52+
get available(): boolean {
53+
return !!window.OCA?.Files?.Sidebar
54+
}
55+
56+
get open(): boolean {
57+
return !!window.OCA?.Files?.Sidebar?.state.file
58+
}
59+
60+
setOpen(open: boolean): void {
61+
if (open) {
62+
window.OCA?.Files?.Sidebar?.open()
63+
} else {
64+
window.OCA?.Files?.Sidebar?.close()
65+
}
66+
}
67+
68+
setActiveTab(tabId: string): void {
69+
window.OCA?.Files?.Sidebar?.setActiveTab(tabId)
70+
}
71+
72+
registerTab(tab: ISidebarTab): void {
73+
registerSidebarTab(tab)
74+
}
75+
76+
getTabs(node?: INode): ISidebarTab[] {
77+
const tabs = getSidebarTabs()
78+
if (node) {
79+
return tabs.filter((tab) => tab.enabled(node))
80+
}
81+
return tabs
82+
}
83+
84+
}
85+
86+
/**
87+
* Get a reference to the files sidebar.
88+
*/
89+
export function getSidebar(): ISidebar {
90+
return new SidebarProxy()
91+
}

lib/sidebar/SidebarTab.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IView } from '../navigation/view.ts'
7+
import type { IFolder, INode } from '../node/index.ts'
8+
9+
import isSvg from 'is-svg'
10+
import logger from '../utils/logger.ts'
11+
12+
export interface ISidebarContext {
13+
/**
14+
* The active node in the sidebar
15+
*/
16+
node: INode
17+
18+
/**
19+
* The current open folder in the files app
20+
*/
21+
folder: IFolder
22+
23+
/**
24+
* The currently active view
25+
*/
26+
view: IView
27+
}
28+
29+
/**
30+
* This component describes the custom web component that should be registered for a sidebar tab.
31+
*
32+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components
33+
* @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue
34+
*/
35+
export interface SidebarComponent extends HTMLElement, ISidebarContext {
36+
/**
37+
* This method is called by the files app if the sidebar tab state changes.
38+
*
39+
* @param active - The new active state
40+
*/
41+
setActive(active: boolean): Promise<void>
42+
}
43+
44+
/**
45+
* Implementation of a custom sidebar tab within the files app.
46+
*/
47+
export interface ISidebarTab {
48+
/**
49+
* Unique id of the sidebar tab.
50+
* This has to conform to the HTML id attribute specification.
51+
*/
52+
id: string
53+
54+
/**
55+
* The localized name of the sidebar tab.
56+
*/
57+
displayName: string
58+
59+
/**
60+
* The icon, as SVG, of the sidebar tab.
61+
*/
62+
iconSvgInline: string
63+
64+
/**
65+
* The order of this tab.
66+
* Use a low number to make this tab ordered in front.
67+
*/
68+
order: number
69+
70+
/**
71+
* The tag name of the web component.
72+
* The web component must already be registered under that tag name with `CustomElementRegistry.define()`.
73+
*
74+
* To avoid name clashes the name has to start with your appid (e.g. `your_app`).
75+
* So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`.
76+
*/
77+
tagName: string
78+
79+
/**
80+
* Callback to check if the sidebar tab should be shown for the selected node.
81+
*
82+
* @param context - The current context of the files app
83+
*/
84+
enabled: (context: ISidebarContext) => boolean
85+
}
86+
87+
/**
88+
* Register a new sidebar tab for the files app.
89+
*
90+
* @param tab - The sidebar tab to register
91+
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
92+
*/
93+
export function registerSidebarTab(tab: ISidebarTab): void {
94+
validateSidebarTab(tab)
95+
96+
window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
97+
if (window._nc_files_sidebar_tabs.has(tab.id)) {
98+
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
99+
return
100+
}
101+
window._nc_files_sidebar_tabs.set(tab.id, tab)
102+
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
103+
}
104+
105+
/**
106+
* Get all currently registered sidebar tabs.
107+
*/
108+
export function getSidebarTabs(): ISidebarTab[] {
109+
if (window._nc_files_sidebar_tabs) {
110+
return [...window._nc_files_sidebar_tabs.values()]
111+
}
112+
return []
113+
}
114+
115+
/**
116+
* Check if a given sidebar tab objects implements all necessary fields.
117+
*
118+
* @param tab - The sidebar tab to validate
119+
*/
120+
function validateSidebarTab(tab: ISidebarTab): void {
121+
if (typeof tab !== 'object') {
122+
throw new Error('Sidebar tab is not an object')
123+
}
124+
125+
if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
126+
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
127+
}
128+
129+
if (!tab.tagName || typeof tab.tagName !== 'string') {
130+
throw new Error('Sidebar tabs need to have the tagName name set')
131+
}
132+
133+
if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) {
134+
throw new Error('Sidebar tabs tagName name is invalid')
135+
}
136+
137+
if (!tab.displayName || typeof tab.displayName !== 'string') {
138+
throw new Error('Sidebar tabs need to have a name set')
139+
}
140+
141+
if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) {
142+
throw new Error('Sidebar tabs need to have an valid SVG icon')
143+
}
144+
145+
if (typeof tab.order !== 'number') {
146+
throw new Error('Sidebar tabs need to have a numeric order set')
147+
}
148+
149+
if (typeof tab.enabled !== 'function') {
150+
throw new Error('Sidebar tabs need to have an "enabled" method')
151+
}
152+
153+
// now check the custom element constructor
154+
const tagConstructor = window.customElements.get(tab.tagName)
155+
if (!tagConstructor) {
156+
throw new Error('Sidebar tab element not registered')
157+
}
158+
159+
if (!('setActive' in tagConstructor.prototype)) {
160+
// we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype.
161+
throw new Error('Sidebar tab elements must have the `setActive` method')
162+
}
163+
}

0 commit comments

Comments
 (0)