Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -170,7 +171,10 @@ export default defineComponent({
activeView,
} = useActiveStore()
const actions = useFileActions()
return {
actions,
actionsMenuStore,
activeFolder,
activeNode,
Expand Down
4 changes: 4 additions & 0 deletions apps/files/src/components/FileEntryGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -122,7 +123,10 @@ export default defineComponent({
activeView,
} = useActiveStore()

const actions = useFileActions()

return {
actions,
actionsMenuStore,
activeFolder,
activeNode,
Expand Down
6 changes: 2 additions & 4 deletions apps/files/src/components/FileEntryMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,8 +24,6 @@ import { isDownloadable } from '../utils/permissions.ts'

Vue.directive('onClickOutside', vOnClickOutside)

const actions = getFileActions()

export default defineComponent({
props: {
source: {
Expand Down Expand Up @@ -233,7 +231,7 @@ export default defineComponent({
return []
}

return actions
return this.actions
.filter((action: IFileAction) => {
if (!action.enabled) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions apps/files/src/components/FilesListHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</template>

<script lang="ts">
import type { Folder, Header, View } from '@nextcloud/files'
import type { Folder, IFileListHeader, View } from '@nextcloud/files'
import type { PropType } from 'vue'

import PQueue from 'p-queue'
Expand All @@ -25,7 +25,7 @@ export default {
name: 'FilesListHeader',
props: {
header: {
type: Object as PropType<Header>,
type: Object as PropType<IFileListHeader>,
required: true,
},

Expand Down
15 changes: 8 additions & 7 deletions apps/files/src/components/FilesListTableHeaderActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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',

Expand Down Expand Up @@ -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) {
Expand All @@ -140,7 +138,10 @@ export default defineComponent({
return 3
})

const actions = useFileActions()

return {
actions,
actionsMenuStore,
activeFolder,
filesStore,
Expand All @@ -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
Expand Down
25 changes: 9 additions & 16 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -362,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
Expand Down
124 changes: 124 additions & 0 deletions apps/files/src/composables/useFileActions.spec.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
42 changes: 42 additions & 0 deletions apps/files/src/composables/useFileActions.ts
Original file line number Diff line number Diff line change
@@ -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)))
}
Loading
Loading