refactor(files): migrate file list actions to new files registry

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-02-10 12:51:25 +01:00
parent c73b85aecb
commit 643a815557
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
5 changed files with 237 additions and 65 deletions

View file

@ -87,6 +87,7 @@ import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import VirtualList from './VirtualList.vue'
import { useEnabledFileActions } from '../composables/useFileActions.ts'
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'

View file

@ -0,0 +1,133 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListAction, INode, registerFileListAction } from '@nextcloud/files'
import type * as composable from './useFileListActions.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, shallowRef } from 'vue'
interface Context {
useFileListActions: typeof composable.useFileListActions
useEnabledFileListActions: typeof composable.useEnabledFileListActions
registerFileListAction: typeof registerFileListAction
}
describe('useFileListActions', () => {
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.useFileListActions = (await import('./useFileListActions.ts')).useFileListActions
context.useEnabledFileListActions = (await import('./useFileListActions.ts')).useEnabledFileListActions
context.registerFileListAction = (await import('@nextcloud/files')).registerFileListAction
})
it<Context>('gets the actions', ({ useFileListActions, registerFileListAction }) => {
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action', exec: vi.fn() }
registerFileListAction(action)
const actions = useFileListActions()
expect(actions.value).toEqual([action])
})
it<Context>('actions are sorted', ({ useFileListActions, registerFileListAction }) => {
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action 1', exec: vi.fn() }
const action2: IFileListAction = { id: '2', order: 0, displayName: () => 'Action 2', exec: vi.fn() }
registerFileListAction(action)
registerFileListAction(action2)
const actions = useFileListActions()
// lower order first
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
})
it<Context>('composable is reactive', async ({ useFileListActions, registerFileListAction }) => {
const action: IFileListAction = { id: '1', order: 5, displayName: () => 'Action', exec: vi.fn() }
registerFileListAction(action)
await nextTick()
const actions = useFileListActions()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
// now add a new action
const action2: IFileListAction = { id: '2', order: 0, displayName: () => 'Action', exec: vi.fn() }
registerFileListAction(action2)
// reactive update, lower order first
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['2', '1'])
})
})
describe('useEnabledFileListActions', () => {
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.useFileListActions = (await import('./useFileListActions.ts')).useFileListActions
context.useEnabledFileListActions = (await import('./useFileListActions.ts')).useEnabledFileListActions
context.registerFileListAction = (await import('@nextcloud/files')).registerFileListAction
})
it<Context>('gets the actions sorted', ({ useEnabledFileListActions, registerFileListAction }) => {
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', exec: vi.fn() })
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, exec: vi.fn() })
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, 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 = useEnabledFileListActions(folder, contents, view)
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
})
it<Context>('composable is reactive', async ({ useEnabledFileListActions, registerFileListAction }) => {
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', exec: vi.fn() })
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: () => false, 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 = useEnabledFileListActions(folder, contents, view)
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1'])
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: () => true, exec: vi.fn() })
expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3'])
})
it<Context>('composable is reactive to context changes', async ({ useEnabledFileListActions, registerFileListAction }) => {
// only enabled if view id === 'enabled-view'
registerFileListAction({ id: '1', order: 0, displayName: () => 'Action 1', enabled: ({ view }) => view.id === 'enabled-view', exec: vi.fn() })
// only enabled if contents has items
registerFileListAction({ id: '2', order: 5, displayName: () => 'Action 2', enabled: ({ contents }) => contents.length > 0, exec: vi.fn() })
// only enabled if folder owner is 'owner2'
registerFileListAction({ id: '3', order: 9, displayName: () => 'Action 3', enabled: ({ folder }) => folder.owner === 'owner2', exec: vi.fn() })
const folder = shallowRef(new Folder({ owner: 'owner', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath }))
const view = shallowRef(new View({ id: 'disabled-view', getContents: vi.fn(), icon: '<svg></svg>', name: 'View' }))
const contents = ref<INode[]>([folder.value])
const actions = useEnabledFileListActions(folder, contents, view)
// 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
contents.value = []
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual([])
// correct owner for action 3
folder.value = new Folder({ owner: 'owner2', root: defaultRootPath, source: defaultRemoteURL + defaultRootPath })
await nextTick()
expect(actions.value.map(({ id }) => id)).toStrictEqual(['3'])
// correct view for action 1
view.value = 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,53 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileListAction, IFolder, INode, IView } from '@nextcloud/files'
import type { MaybeRefOrGetter } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { getFileListActions, getFilesRegistry } from '@nextcloud/files'
import { toValue } from '@vueuse/core'
import { computed, ref } from 'vue'
const actions = ref<IFileListAction[]>()
const sorted = computed(() => [...(actions.value ?? [])].sort((a, b) => a.order - b.order))
/**
* Get the registered and sorted file list actions.
*/
export function useFileListActions(): ComputedRef<IFileListAction[]> {
if (!actions.value) {
// if not initialized by other component yet, initialize and subscribe to registry changes
actions.value = getFileListActions()
getFilesRegistry().addEventListener('register:listAction', () => {
actions.value = getFileListActions()
})
}
return sorted
}
/**
* Get the enabled file list actions for the given folder, contents and view.
*
* @param folder - The current folder
* @param contents - The contents of the current folder
* @param view - The current view
*/
export function useEnabledFileListActions(
folder: MaybeRefOrGetter<IFolder | undefined>,
contents: MaybeRefOrGetter<INode[]>,
view: MaybeRefOrGetter<IView | undefined>,
) {
const actions = useFileListActions()
return computed(() => {
if (toValue(folder) === undefined || toValue(view) === undefined) {
return []
}
return actions.value.filter((action) => action.enabled === undefined
|| action.enabled({ folder: toValue(folder)!, contents: toValue(contents), view: toValue(view)! }))
})
}

View file

@ -1,15 +1,30 @@
/**
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFileAction, IFolder, INode, IView } from '@nextcloud/files'
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe } from '@nextcloud/event-bus'
import { getNavigation } from '@nextcloud/files'
import { Folder, getNavigation, Permission } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { defineStore } from 'pinia'
import { ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
import { useFilesStore } from './files.ts'
// Temporary fake folder to use until we have the first valid folder
// fetched and cached. This allow us to mount the FilesListVirtual
// at all time and avoid unmount/mount and undesired rendering issues.
const dummyFolder = new Folder({
id: 0,
source: getRemoteURL() + getRootPath(),
root: getRootPath(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.NONE,
})
export const useActiveStore = defineStore('active', () => {
/**
@ -17,11 +32,6 @@ export const useActiveStore = defineStore('active', () => {
*/
const activeAction = shallowRef<IFileAction>()
/**
* The currently active folder
*/
const activeFolder = ref<IFolder>()
/**
* The current active node within the folder
*/
@ -32,6 +42,20 @@ export const useActiveStore = defineStore('active', () => {
*/
const activeView = shallowRef<IView>()
const filesStore = useFilesStore()
const { directory } = useRouteParameters()
/**
* The currently active folder
*/
const activeFolder = computed<IFolder>(() => {
if (!activeView.value?.id) {
return dummyFolder
}
return filesStore.getDirectoryByPath(activeView.value.id, directory.value)
?? dummyFolder
})
// Set the active node on the router params
watch(activeNode, () => {
if (typeof activeNode.value?.fileid !== 'number' || activeNode.value.fileid === activeFolder.value?.fileid) {

View file

@ -160,11 +160,9 @@ import type { ComponentPublicInstance } from 'vue'
import type { Route } from 'vue-router'
import type { UserConfig } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Folder, getFileListActions, Permission, sortNodes } from '@nextcloud/files'
import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { Permission, sortNodes } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { dirname, join } from '@nextcloud/paths'
@ -189,6 +187,7 @@ import BreadCrumbs from '../components/BreadCrumbs.vue'
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
import FileListFilters from '../components/FileListFilter/FileListFilters.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import { useEnabledFileListActions } from '../composables/useFileListActions.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import logger from '../logger.ts'
@ -258,13 +257,27 @@ export default defineComponent({
const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
const currentView = computed(() => activeStore.activeView)
const currentFolder = computed(() => activeStore.activeFolder)
const dirContents = computed<INode[]>(() => {
const sources = (currentFolder.value as { _children?: string[] })?._children ?? []
return sources.map(filesStore.getNode)
.filter(Boolean) as INode[]
})
const enabledFileListActions = useEnabledFileListActions(
currentFolder,
dirContents,
currentView,
)
return {
currentFolder,
currentView,
dirContents,
directory,
enabledFileListActions,
fileId,
isNarrow,
t,
sidebar,
activeStore,
@ -280,6 +293,7 @@ export default defineComponent({
enableGridView,
forbiddenCharacters,
ShareType,
t,
}
},
@ -329,34 +343,6 @@ export default defineComponent({
return `${this.currentFolder.displayname} - ${title}`
},
/**
* The current folder.
*/
currentFolder(): Folder {
// Temporary fake folder to use until we have the first valid folder
// fetched and cached. This allow us to mount the FilesListVirtual
// at all time and avoid unmount/mount and undesired rendering issues.
const dummyFolder = new Folder({
id: 0,
source: getRemoteURL() + getRootPath(),
root: getRootPath(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.NONE,
})
if (!this.currentView?.id) {
return dummyFolder
}
return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
},
dirContents(): Node[] {
return (this.currentFolder?._children || [])
.map(this.filesStore.getNode)
.filter((node: Node) => !!node)
},
/**
* The current directory contents.
*/
@ -445,27 +431,6 @@ export default defineComponent({
return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined
},
enabledFileListActions() {
if (!this.currentView || !this.currentFolder) {
return []
}
const actions = getFileListActions()
const enabledActions = actions
.filter((action) => {
if (action.enabled === undefined) {
return true
}
return action.enabled({
view: this.currentView!,
folder: this.currentFolder!,
contents: this.dirContents,
})
})
.toSorted((a, b) => a.order - b.order)
return enabledActions
},
/**
* Using the filtered content if filters are active
*/
@ -495,10 +460,6 @@ export default defineComponent({
}
},
currentFolder() {
this.activeStore.activeFolder = this.currentFolder
},
currentView(newView, oldView) {
if (newView?.id === oldView?.id) {
return