diff --git a/src/components/NoteItem.vue b/src/components/NoteItem.vue
index 535bfd6e0..635b5af52 100644
--- a/src/components/NoteItem.vue
+++ b/src/components/NoteItem.vue
@@ -42,6 +42,13 @@
{{ t('notes', 'Share') }}
+
+
+
+
+ {{ t('notes', 'Versions') }}
+
+
@@ -106,6 +113,7 @@ import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AlertOctagonOutlineIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
+import BackupRestoreIcon from 'vue-material-design-icons/BackupRestore.vue'
import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue'
import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue'
import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
@@ -120,6 +128,7 @@ export default {
components: {
AlertOctagonOutlineIcon,
+ BackupRestoreIcon,
FolderOutlineIcon,
NcActionButton,
NcListItem,
@@ -337,6 +346,11 @@ export default {
emit('notes:share:open', { noteId: this.note.id })
},
+ onShowVersions() {
+ this.actionsOpen = false
+ emit('notes:sidebar:open', { noteId: this.note.id, tab: 'files_versions' })
+ },
+
async onShareCreated(event) {
const { share } = event
diff --git a/src/components/NoteShareSidebar.vue b/src/components/NoteShareSidebar.vue
index 095ee0efc..9fb465a40 100644
--- a/src/components/NoteShareSidebar.vue
+++ b/src/components/NoteShareSidebar.vue
@@ -9,20 +9,20 @@
data-cy-notes-share-sidebar
forceMenu
:loading="isOpen && loading"
- :name="note?.title || t('notes', 'Share')"
+ :name="note?.title || t('notes', 'Note')"
noToggle
:open="isOpen"
@closed="onClosed"
@update:open="onToggle"
>
-
-
+
@@ -33,26 +33,26 @@
-
+
- {{ error || t('notes', 'Unable to load the selected note for sharing.') }}
+ {{ error || t('notes', 'Unable to load the selected note.') }}
-
+
-
+
- {{ t('notes', 'Sharing is not available right now.') }}
+ {{ t('notes', 'Sharing and versions are not available right now.') }}
@@ -65,8 +65,9 @@ import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
-import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
+import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue'
import logger from '../Logger.js'
+import { selectNoteSidebarTabs } from '../sidebarTabs.js'
import store from '../store.js'
import { fetchDavNode } from '../WebdavService.js'
@@ -79,7 +80,7 @@ export default {
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
- ShareVariantOutlineIcon,
+ FileOutlineIcon,
},
data() {
@@ -115,8 +116,12 @@ export default {
return store.notes.getNote(this.noteId)
},
- sharingTab() {
- return getSidebarTabs().find((tab) => tab.id === 'sharing') || null
+ tabs() {
+ return selectNoteSidebarTabs(getSidebarTabs(), {
+ node: this.currentNode,
+ folder: this.currentFolder,
+ view: this.currentView,
+ })
},
currentView() {
@@ -128,57 +133,74 @@ export default {
},
mounted() {
+ // the share event is kept so anything already emitting it keeps working
subscribe('notes:share:open', this.onShareOpen)
+ subscribe('notes:sidebar:open', this.onSidebarOpen)
},
unmounted() {
unsubscribe('notes:share:open', this.onShareOpen)
+ unsubscribe('notes:sidebar:open', this.onSidebarOpen)
},
methods: {
- async initializeSharingTab() {
- const tab = this.sharingTab
- if (!tab) {
+ async initializeTabs() {
+ const tabs = this.tabs
+ if (tabs.length === 0) {
this.loadingTab = false
- this.tabError = this.t('notes', 'Sharing is not available right now.')
+ this.tabError = this.t('notes', 'Sharing and versions are not available right now.')
return
}
+ // One tab failing to define its element must not hide the others, so
+ // they are initialised independently and only a total failure is
+ // reported as an error.
+ const results = await Promise.all(tabs.map((tab) => this.initializeTab(tab)))
+
+ this.loadingTab = false
+ this.tabError = results.includes(true)
+ ? ''
+ : this.t('notes', 'Failed to load the note sidebar.')
+ },
+
+ /**
+ * @param {object} tab a registered Files sidebar tab
+ * @return {Promise} whether the tab is usable
+ */
+ async initializeTab(tab) {
if (window.customElements.get(tab.tagName) || this.initializedTabs.has(tab.tagName)) {
- this.loadingTab = false
- this.tabError = ''
- return
+ return true
}
if (this.initializingTabs.has(tab.tagName)) {
+ // another open is already awaiting this one
this.loadingTab = true
- return
+ return true
}
this.initializingTabs.add(tab.tagName)
this.loadingTab = true
- this.tabError = ''
try {
await tab.onInit?.()
await window.customElements.whenDefined(tab.tagName)
this.initializedTabs.add(tab.tagName)
+ return true
} catch (error) {
- logger.error('Failed to initialize the sharing sidebar tab in Notes', { error })
- this.tabError = this.t('notes', 'Failed to load the sharing sidebar.')
+ logger.error('Failed to initialize a sidebar tab in Notes', { error, tab: tab.id })
+ return false
} finally {
this.initializingTabs.delete(tab.tagName)
- this.loadingTab = false
}
},
- async loadShareContext() {
+ async loadNodeContext() {
const internalPath = this.note?.internalPath
if (!internalPath) {
this.loadingContext = false
this.currentNode = null
this.currentFolder = null
- this.contextError = this.t('notes', 'Unable to load the selected note for sharing.')
+ this.contextError = this.t('notes', 'Unable to load the selected note.')
return
}
@@ -193,7 +215,7 @@ export default {
try {
folder = await fetchDavNode(node.dirname || '/')
} catch (error) {
- logger.error('Failed to load the parent folder for the Notes sharing sidebar', { error })
+ logger.error('Failed to load the parent folder for the Notes sidebar', { error })
}
if (requestToken !== this.contextRequestToken) {
@@ -207,10 +229,10 @@ export default {
return
}
- logger.error('Failed to load the selected note for the Notes sharing sidebar', { error })
+ logger.error('Failed to load the selected note for the Notes sidebar', { error })
this.currentNode = null
this.currentFolder = null
- this.contextError = this.t('notes', 'Unable to load the selected note for sharing.')
+ this.contextError = this.t('notes', 'Unable to load the selected note.')
} finally {
if (requestToken === this.contextRequestToken) {
this.loadingContext = false
@@ -218,10 +240,14 @@ export default {
}
},
- async onShareOpen({ noteId }) {
+ onShareOpen({ noteId }) {
+ return this.onSidebarOpen({ noteId, tab: 'sharing' })
+ },
+
+ async onSidebarOpen({ noteId, tab = 'sharing' }) {
this.contextRequestToken += 1
this.noteId = Number(noteId)
- this.activeTab = 'sharing'
+ this.activeTab = tab
this.isOpen = true
this.contextError = ''
this.tabError = ''
@@ -230,14 +256,14 @@ export default {
this.loadingContext = false
this.loadingTab = false
- if (!this.sharingTab) {
- await this.initializeSharingTab()
+ if (this.tabs.length === 0) {
+ await this.initializeTabs()
return
}
await Promise.all([
- this.initializeSharingTab(),
- this.loadShareContext(),
+ this.initializeTabs(),
+ this.loadNodeContext(),
])
},
diff --git a/src/sidebarTabs.js b/src/sidebarTabs.js
new file mode 100644
index 000000000..016cc1844
--- /dev/null
+++ b/src/sidebarTabs.js
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Files sidebar tabs the Notes sidebar hosts, and nothing else.
+ *
+ * Notes dispatches OCA\Files\Event\LoadSidebar when rendering its page, so every
+ * app that registers a sidebar tab has registered one by the time this runs —
+ * including tabs that make no sense for a note. This is an allow-list so a newly
+ * installed app cannot start appearing in the Notes sidebar unannounced.
+ *
+ * @type {string[]}
+ */
+export const NOTE_SIDEBAR_TAB_IDS = ['sharing', 'files_versions']
+
+/**
+ * The tabs to render, in the order the registering apps asked for.
+ *
+ * A tab's own `enabled()` predicate has the final say — the versions tab for
+ * instance hides itself on public shares and for anything that is not a file —
+ * but it needs a node to judge, so while the node is still loading the tabs are
+ * kept and filtered again once it arrives. A predicate that throws is treated as
+ * "not usable" rather than being allowed to take the sidebar down.
+ *
+ * @param {Array