refactor(files): migrate to files registry for reactive file actions

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-02-10 13:00:54 +01:00
parent 643a815557
commit 38644873f2
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
8 changed files with 197 additions and 30 deletions

View file

@ -115,6 +115,7 @@ import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useFileActions } from '../composables/useFileActions.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
@ -170,7 +171,10 @@ export default defineComponent({
activeView,
} = useActiveStore()
const actions = useFileActions()
return {
actions,
actionsMenuStore,
activeFolder,
activeNode,

View file

@ -77,6 +77,7 @@ import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import { useFileActions } from '../composables/useFileActions.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
@ -122,7 +123,10 @@ export default defineComponent({
activeView,
} = useActiveStore()
const actions = useFileActions()
return {
actions,
actionsMenuStore,
activeFolder,
activeNode,

View file

@ -8,7 +8,7 @@ import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getFileActions, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
import { FileType, Folder, File as NcFile, Node, NodeStatus, Permission } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
@ -24,8 +24,6 @@ import { isDownloadable } from '../utils/permissions.ts'
Vue.directive('onClickOutside', vOnClickOutside)
const actions = getFileActions()
export default defineComponent({
props: {
source: {
@ -233,7 +231,7 @@ export default defineComponent({
return []
}
return actions
return this.actions
.filter((action: IFileAction) => {
if (!action.enabled) {
return true

View file

@ -6,20 +6,22 @@
<script setup lang="ts">
import type { IHotkeyConfig } from '@nextcloud/files'
import { getFileActions } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcAppSettingsShortcutsSection from '@nextcloud/vue/components/NcAppSettingsShortcutsSection'
import NcHotkey from '@nextcloud/vue/components/NcHotkey'
import NcHotkeyList from '@nextcloud/vue/components/NcHotkeyList'
import { useFileActions } from '../../composables/useFileActions.ts'
const actionHotkeys = getFileActions()
const actions = useFileActions()
const actionHotkeys = computed(() => actions.value
.filter((action) => !!action.hotkey)
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((action) => ({
id: action.id,
label: action.hotkey!.description,
hotkey: hotkeyToString(action.hotkey!),
}))
})))
/**
* Convert a hotkey configuration to a hotkey string.

View file

@ -12,7 +12,7 @@
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
:inline="enabledInlineActions.length"
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null"
:menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : undefined"
@close="openedSubmenu = null">
<!-- Default actions list-->
<NcActionButton
@ -75,7 +75,7 @@ import type { PropType } from 'vue'
import type { FileSource } from '../types.ts'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { DefaultType, getFileActions, NodeStatus } from '@nextcloud/files'
import { DefaultType, NodeStatus } from '@nextcloud/files'
import { translate } from '@nextcloud/l10n'
import { computed, defineComponent } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
@ -84,6 +84,7 @@ import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue'
import { useFileActions } from '../composables/useFileActions.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import logger from '../logger.ts'
import actionsMixins from '../mixins/actionsMixin.ts'
@ -92,9 +93,6 @@ import { useActiveStore } from '../store/active.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
// The registered actions list
const actions = getFileActions()
export default defineComponent({
name: 'FilesListTableHeaderActions',
@ -128,7 +126,7 @@ export default defineComponent({
const selectionStore = useSelectionStore()
const { isMedium, isNarrow } = useFileListWidth()
const boundariesElement = document.getElementById('app-content-vue')
const boundariesElement = document.getElementById('app-content-vue') as HTMLElement
const inlineActions = computed(() => {
if (isNarrow.value) {
@ -140,7 +138,10 @@ export default defineComponent({
return 3
})
const actions = useFileActions()
return {
actions,
actionsMenuStore,
activeFolder,
filesStore,
@ -159,7 +160,7 @@ export default defineComponent({
computed: {
enabledFileActions(): IFileAction[] {
return actions
return this.actions
// We don't handle renderInline actions in this component
.filter((action) => !action.renderInline)
// We don't handle actions that are not visible

View file

@ -74,7 +74,7 @@ import type { ComponentPublicInstance, PropType } from 'vue'
import type { UserConfig } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getFileActions, getSidebar, Permission, View } from '@nextcloud/files'
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, defineComponent } from 'vue'
@ -363,21 +363,13 @@ export default defineComponent({
}
if (node.type === FileType.File) {
const defaultAction = getFileActions()
// Get only default actions (visible and hidden)
.filter((action) => !!action?.default)
// Find actions that are either always enabled or enabled for the current node
.filter((action) => (!action.enabled || action.enabled({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})))
.filter((action) => action.id !== 'download')
// Sort enabled default actions by order
.sort((a, b) => (a.order || 0) - (b.order || 0))
// Get the first one
.at(0)
const actions = useEnabledFileActions({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})
const defaultAction = actions.value.find((action) => action.id !== 'download' && !!action.default)
// Some file types do not have a default action (e.g. they can only be downloaded)
// So if there is an enabled default action, so execute it

View file

@ -0,0 +1,124 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileAction, INode, registerFileAction } from '@nextcloud/files'
import type * as composable from './useFileActions.ts'
import { Folder, View } from '@nextcloud/files'
import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
interface Context {
useFileActions: typeof composable.useFileActions
useEnabledFileActions: typeof composable.useEnabledFileActions
registerFileAction: typeof registerFileAction
}
describe('useFileActions', () => {
beforeEach(async (context: Context) => {
delete globalThis._nc_files_scope
// reset modules to reset internal variables (the headers ref) of the composable and the library (the scoped globals)
vi.resetModules()
context.useFileActions = (await import('./useFileActions.ts')).useFileActions
context.useEnabledFileActions = (await import('./useFileActions.ts')).useEnabledFileActions
context.registerFileAction = (await import('@nextcloud/files')).registerFileAction
})
it<Context>('gets the actions', ({ useFileActions, registerFileAction }) => {
const action: IFileAction = { id: '1', order: 5, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
registerFileAction(action)
const actions = useFileActions()
expect(actions.value).toEqual([action])
})
it<Context>('composable is reactive', async ({ useFileActions, registerFileAction }) => {
const action: IFileAction = { id: '1', order: 5, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
registerFileAction(action)
await nextTick()
const actions = useFileActions()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
// now add a new action
const action2: IFileAction = { id: '2', order: 9, displayName: () => 'Action', iconSvgInline: vi.fn(), exec: vi.fn() }
registerFileAction(action2)
// reactive update, lower order first
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '2'])
})
})
describe('useEnabledFileActions', () => {
beforeEach(async (context: Context) => {
delete globalThis._nc_files_scope
// reset modules to reset internal variables (the headers ref) of the composable and the library (the scoped globals)
vi.resetModules()
context.useFileActions = (await import('./useFileActions.ts')).useFileActions
context.useEnabledFileActions = (await import('./useFileActions.ts')).useEnabledFileActions
context.registerFileAction = (await import('@nextcloud/files')).registerFileAction
})
it<Context>('gets the actions', ({ useEnabledFileActions, registerFileAction }) => {
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', iconSvgInline: vi.fn(), exec: vi.fn() })
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, iconSvgInline: vi.fn(), exec: vi.fn() })
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, iconSvgInline: vi.fn(), exec: vi.fn() })
const folder = new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
const view = new View({ id: 'view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
const contents = []
const actions = useEnabledFileActions({ folder, contents, view })
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
})
it<Context>('composable is reactive', async ({ useEnabledFileActions, registerFileAction }) => {
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', iconSvgInline: vi.fn(), exec: vi.fn() })
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, iconSvgInline: vi.fn(), exec: vi.fn() })
const folder = new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
const view = new View({ id: 'view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
const contents = []
const actions = useEnabledFileActions({ folder, contents, view })
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, iconSvgInline: vi.fn(), exec: vi.fn() })
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
})
it<Context>('composable is reactive to context changes', async ({ useEnabledFileActions, registerFileAction }) => {
// only enabled if view id === 'enabled-view'
registerFileAction({ id: '1', order: 0, displayName: () => 'Action 1', enabled: ({ view }) => view.id === 'enabled-view', iconSvgInline: vi.fn(), exec: vi.fn() })
// only enabled if contents has items
registerFileAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: ({ contents }) => contents.length > 0, iconSvgInline: vi.fn(), exec: vi.fn() })
// only enabled if folder owner is 'owner2'
registerFileAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: ({ folder }) => folder.owner === 'owner2', iconSvgInline: vi.fn(), exec: vi.fn() })
const context = ref({
folder: new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }),
view: new View({ id: 'disabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' }),
contents: ref<INode[]>([(new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }))]),
})
const actions = useEnabledFileActions(context)
// we have contents but wrong folder and view so only 2 is enabled
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2'])
// no contents so nothing is enabled
context.value.contents = []
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual([])
// correct owner for action 3
context.value.folder = new Folder({ owner: 'owner2', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['3'])
// correct view for action 1
context.value.view = new View({ id: 'enabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' })
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
})
})

View file

@ -0,0 +1,42 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ActionContext, IFileAction } from '@nextcloud/files'
import type { MaybeRefOrGetter } from '@vueuse/core'
import type { Ref } from 'vue'
import { getFileActions, getFilesRegistry } from '@nextcloud/files'
import { toValue } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
const actions = ref<IFileAction[] | undefined>()
/**
* Get the registered and sorted file actions.
*/
export function useFileActions() {
if (!actions.value) {
// if not initialized by other component yet, initialize and subscribe to registry changes
actions.value = getFileActions()
getFilesRegistry().addEventListener('register:action', () => {
actions.value = getFileActions()
})
}
return readonly(actions as Ref<IFileAction[]>)
}
/**
* Get the enabled file actions for the given context.
*
* @param context - The context to check the enabled state of the actions against
*/
export function useEnabledFileActions(context: MaybeRefOrGetter<ActionContext>) {
const actions = useFileActions()
return computed(() => actions.value
.filter((action) => action.enabled === undefined
|| action.enabled(toValue(context)!))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)))
}