Skip to content

Commit 2884d99

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 ccf8eb7 commit 2884d99

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 { IFolder, INode } from '../node/index.ts'
7+
import logger from '../utils/logger.ts'
8+
9+
interface SidebarUpdateContext {
10+
/**
11+
* The currently selected node for which the sidebar is shown.
12+
*/
13+
node: INode
14+
15+
/**
16+
* The parent of the current selected node.
17+
* This is not necessarily the real parent folder of the node in means of the real filesystem tree,
18+
* but rather the parent folder in the current view of the files app.
19+
*/
20+
parent: IFolder
21+
}
22+
23+
/**
24+
* Implementation of a custom sidebar tab within the files app.
25+
*/
26+
export interface ISidebarTab {
27+
/**
28+
* Unique id of the sidebar tab.
29+
* This has to conform to the HTML id attribute specification.
30+
*/
31+
id: string
32+
33+
/**
34+
* The localized name of the sidebar tab.
35+
*/
36+
displayName: string
37+
38+
/**
39+
* The icon, as SVG, of the sidebar tab.
40+
*/
41+
iconSvg: string
42+
43+
/**
44+
* The order of this tab.
45+
* Use a low number to make this tab ordered in front.
46+
*/
47+
order: number
48+
49+
/**
50+
* Callback to check if the sidebar tab should be shown for the selected node.
51+
*
52+
* @param node - The currently selected node
53+
*/
54+
enabled: (node: INode) => boolean
55+
56+
/**
57+
* Called by the files app if this tab has become the active tab or was deactivated.
58+
*
59+
* @param active - The new active state of this tab
60+
*/
61+
setActive: (active: boolean) => void | Promise<void>
62+
63+
/**
64+
* The lifecylce method for mounting the sidebar tab onto the sidebar.
65+
*
66+
* @param el - The element to mount the sidebar tab to
67+
* @param context - The current sidebar context
68+
*/
69+
mount: (el: HTMLElement, context: SidebarUpdateContext) => void | Promise<void>
70+
71+
/**
72+
* The lifecycle method for updating the sidebar tab.
73+
* This is called if the currently selected node changes.
74+
*
75+
* @param context - The current sidebar context
76+
*/
77+
update: (context: SidebarUpdateContext) => void | Promise<void>
78+
79+
/**
80+
* The lifecycle method for unmounting the sidebar tab.
81+
* This is called if the sidebar is unmounted from the files app and thus the sidebar tab needs to do its cleanup and unmounting.
82+
*/
83+
unmount: () => void | Promise<void>
84+
85+
/**
86+
* Called when the bottom of the sidebar was reached during scrolling.
87+
*/
88+
onScrollBottomReached?: () => void | Promise<void>
89+
}
90+
91+
/**
92+
* Register a new sidebar tab for the files app.
93+
*
94+
* @param tab - The sidebar tab to register
95+
* @throws If the provided tab is not a valid sidebar tab and thus cannot be registered.
96+
*/
97+
export function registerSidebarTab(tab: ISidebarTab): void {
98+
validateSidebarTab(tab)
99+
100+
window._nc_files_sidebar_tabs ??= new Map<string, ISidebarTab>()
101+
if (window._nc_files_sidebar_tabs.has(tab.id)) {
102+
logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`)
103+
return
104+
}
105+
window._nc_files_sidebar_tabs.set(tab.id, tab)
106+
logger.debug(`New sidebar tab with id "${tab.id}" registered.`)
107+
}
108+
109+
/**
110+
* Get all currently registered sidebar tabs.
111+
*/
112+
export function getSidebarTabs(): ISidebarTab[] {
113+
if (window._nc_files_sidebar_tabs) {
114+
return [...window._nc_files_sidebar_tabs.values()]
115+
}
116+
return []
117+
}
118+
119+
/**
120+
* Check if a given sidebar tab objects implements all necessary fields.
121+
*
122+
* @param tab - The sidebar tab to validate
123+
*/
124+
function validateSidebarTab(tab: ISidebarTab): void {
125+
if (typeof tab !== 'object') {
126+
throw new Error('Sidebar tab is not an object')
127+
}
128+
129+
if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) {
130+
throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications')
131+
}
132+
133+
if (!tab.displayName || typeof tab.displayName !== 'string') {
134+
throw new Error('Sidebar tabs need to have a name set')
135+
}
136+
137+
if (typeof tab.iconSvg !== 'string' || !tab.iconSvg.match(/^<svg.+<\/svg>$/i)) {
138+
throw new Error('Sidebar tabs need to have an valid SVG icon')
139+
}
140+
141+
if (typeof tab.order !== 'number') {
142+
throw new Error('Sidebar tabs need to have a numeric order set')
143+
}
144+
145+
if (typeof tab.enabled !== 'function') {
146+
throw new Error('Sidebar tabs need to have an "enabled" method')
147+
}
148+
149+
if (typeof tab.setActive !== 'function') {
150+
throw new Error('Sidebar tabs need to have a "setActive" method')
151+
}
152+
153+
if (typeof tab.mount !== 'function'
154+
|| typeof tab.update !== 'function'
155+
|| typeof tab.unmount !== 'function'
156+
) {
157+
throw new Error('Sidebar tab is missing a required lifecycle method')
158+
}
159+
160+
if (tab.onScrollBottomReached && typeof tab.onScrollBottomReached !== 'function') {
161+
throw new Error('"onScrollBottomReached" of the sidebar tab needs to be a function')
162+
}
163+
}

0 commit comments

Comments
 (0)