diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index f642836a2b7..a38ed8216fe 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -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' diff --git a/apps/files/src/composables/useFileListActions.spec.ts b/apps/files/src/composables/useFileListActions.spec.ts new file mode 100644 index 00000000000..782f83c4d82 --- /dev/null +++ b/apps/files/src/composables/useFileListActions.spec.ts @@ -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('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('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('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('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: '', name: 'View' }) + const contents = [] + const actions = useEnabledFileListActions(folder, contents, view) + expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3']) + }) + + it('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: '', 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('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: '', name: 'View' })) + const contents = ref([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: '', name: 'View' }) + await nextTick() + expect(actions.value.map(({ id }) => id)).toStrictEqual(['1', '3']) + }) +}) diff --git a/apps/files/src/composables/useFileListActions.ts b/apps/files/src/composables/useFileListActions.ts new file mode 100644 index 00000000000..1dda302f618 --- /dev/null +++ b/apps/files/src/composables/useFileListActions.ts @@ -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() +const sorted = computed(() => [...(actions.value ?? [])].sort((a, b) => a.order - b.order)) + +/** + * Get the registered and sorted file list actions. + */ +export function useFileListActions(): ComputedRef { + 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, + contents: MaybeRefOrGetter, + view: MaybeRefOrGetter, +) { + 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)! })) + }) +} diff --git a/apps/files/src/store/active.ts b/apps/files/src/store/active.ts index b1703194882..df8f43fd93b 100644 --- a/apps/files/src/store/active.ts +++ b/apps/files/src/store/active.ts @@ -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() - /** - * The currently active folder - */ - const activeFolder = ref() - /** * The current active node within the folder */ @@ -32,6 +42,20 @@ export const useActiveStore = defineStore('active', () => { */ const activeView = shallowRef() + const filesStore = useFilesStore() + const { directory } = useRouteParameters() + /** + * The currently active folder + */ + const activeFolder = computed(() => { + 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) { diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 80bc4f1081f..f8afea583b8 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -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('files', 'forbiddenCharacters', []) const currentView = computed(() => activeStore.activeView) + const currentFolder = computed(() => activeStore.activeFolder) + const dirContents = computed(() => { + 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