From 47acb66b9c3c14514d38ec6e140d0e54e1987e93 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 5 Jan 2026 18:25:05 +0100 Subject: [PATCH] refactor(files): migrate from deprecated `useNavigation` to `activeStore` Small preparation for upcoming Vue 3 migration of the files app. Signed-off-by: Ferdinand Thiessen --- apps/files/src/actions/moveOrCopyAction.ts | 25 ++--- .../src/actions/moveOrCopyActionUtils.ts | 21 ++-- apps/files/src/components/BreadCrumbs.vue | 24 ++--- .../src/components/DragAndDropNotice.vue | 15 +-- .../src/components/FilesListTableHeader.vue | 20 ++-- .../src/components/FilesNavigationItem.vue | 10 +- .../src/components/FilesNavigationSearch.vue | 6 +- .../src/composables/useNavigation.spec.ts | 99 ------------------- apps/files/src/composables/useNavigation.ts | 57 ----------- apps/files/src/composables/useViews.spec.ts | 63 ++++++++++++ apps/files/src/composables/useViews.ts | 36 +++++++ apps/files/src/mixins/filesSorting.ts | 21 ++-- apps/files/src/services/DropService.ts | 17 ++-- apps/files/src/services/DropServiceUtils.ts | 27 ++--- apps/files/src/views/FilesList.vue | 23 ++--- apps/files/src/views/FilesNavigation.vue | 12 ++- build/frontend-legacy/tsconfig.json | 2 +- 17 files changed, 216 insertions(+), 262 deletions(-) delete mode 100644 apps/files/src/composables/useNavigation.spec.ts delete mode 100644 apps/files/src/composables/useNavigation.ts create mode 100644 apps/files/src/composables/useViews.spec.ts create mode 100644 apps/files/src/composables/useViews.ts diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts index 12c3155aeec..7827fc06b29 100644 --- a/apps/files/src/actions/moveOrCopyAction.ts +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -1,9 +1,10 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + import type { IFilePickerButton } from '@nextcloud/dialogs' -import type { Folder, Node } from '@nextcloud/files' +import type { IFolder, INode } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav' import type { MoveCopyResult } from './moveOrCopyActionUtils.ts' @@ -14,7 +15,7 @@ import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERM import { emit } from '@nextcloud/event-bus' import { FileAction, FileType, getUniqueName, NodeStatus, Permission } from '@nextcloud/files' import { defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import { hasConflict, openConflictPicker } from '@nextcloud/upload' import { basename, join } from 'path' import Vue from 'vue' @@ -28,7 +29,7 @@ import { canCopy, canMove, getQueue, MoveCopyAction } from './moveOrCopyActionUt * @param nodes The nodes to check against * @return The action that is possible for the given nodes */ -function getActionForNodes(nodes: Node[]): MoveCopyAction { +function getActionForNodes(nodes: INode[]): MoveCopyAction { if (canMove(nodes)) { if (canCopy(nodes)) { return MoveCopyAction.MOVE_OR_COPY @@ -76,7 +77,7 @@ function createLoadingNotification(mode: MoveCopyAction, source: string, destina * @param overwrite Whether to overwrite the destination if it exists * @return A promise that resolves when the copy/move is done */ -export async function handleCopyMoveNodeTo(node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) { +export async function handleCopyMoveNodeTo(node: INode, destination: IFolder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) { if (!destination) { return } @@ -217,13 +218,13 @@ export async function handleCopyMoveNodeTo(node: Node, destination: Folder, meth async function openFilePickerForAction( action: MoveCopyAction, dir = '/', - nodes: Node[], + nodes: INode[], ): Promise { const { resolve, reject, promise } = Promise.withResolvers() const fileIDs = nodes.map((node) => node.fileid).filter(Boolean) const filePicker = getFilePickerBuilder(t('files', 'Choose destination')) .allowDirectories(true) - .setFilter((n: Node) => { + .setFilter((n: INode) => { // We don't want to show the current nodes in the file picker return !fileIDs.includes(n.fileid) }) @@ -234,7 +235,7 @@ async function openFilePickerForAction( .setMimeTypeFilter([]) .setMultiSelect(false) .startAt(dir) - .setButtonFactory((selection: Node[], path: string) => { + .setButtonFactory((selection: INode[], path: string) => { const buttons: IFilePickerButton[] = [] const target = basename(path) @@ -246,9 +247,9 @@ async function openFilePickerForAction( label: target ? t('files', 'Copy to {target}', { target }, { escape: false, sanitize: false }) : t('files', 'Copy'), variant: 'primary', icon: CopyIconSvg, - async callback(destination: Node[]) { + async callback(destination: INode[]) { resolve({ - destination: destination[0] as Folder, + destination: destination[0] as IFolder, action: MoveCopyAction.COPY, } as MoveCopyResult) }, @@ -276,9 +277,9 @@ async function openFilePickerForAction( label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'), variant: action === MoveCopyAction.MOVE ? 'primary' : 'secondary', icon: FolderMoveSvg, - async callback(destination: Node[]) { + async callback(destination: INode[]) { resolve({ - destination: destination[0] as Folder, + destination: destination[0] as IFolder, action: MoveCopyAction.MOVE, } as MoveCopyResult) }, diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts index d617b6af006..4a74282c274 100644 --- a/apps/files/src/actions/moveOrCopyActionUtils.ts +++ b/apps/files/src/actions/moveOrCopyActionUtils.ts @@ -1,9 +1,9 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Folder, Node } from '@nextcloud/files' +import type { IFolder, INode } from '@nextcloud/files' import type { ShareAttribute } from '../../../files_sharing/src/sharing.ts' import { Permission } from '@nextcloud/files' @@ -36,24 +36,26 @@ export enum MoveCopyAction { } export type MoveCopyResult = { - destination: Folder + destination: IFolder action: MoveCopyAction.COPY | MoveCopyAction.MOVE } /** + * Check if the given nodes can be moved * - * @param nodes + * @param nodes - The nodes to check */ -export function canMove(nodes: Node[]) { +export function canMove(nodes: INode[]) { const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL) return Boolean(minPermission & Permission.DELETE) } /** + * Check if the given nodes can be downloaded * - * @param nodes + * @param nodes - The nodes to check */ -export function canDownload(nodes: Node[]) { +export function canDownload(nodes: INode[]) { return nodes.every((node) => { const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array return !shareAttributes.some((attribute) => attribute.scope === 'permissions' && attribute.value === false && attribute.key === 'download') @@ -61,10 +63,11 @@ export function canDownload(nodes: Node[]) { } /** + * Check if the given nodes can be copied * - * @param nodes + * @param nodes - The nodes to check */ -export function canCopy(nodes: Node[]) { +export function canCopy(nodes: INode[]) { // a shared file cannot be copied if the download is disabled if (!canDownload(nodes)) { return false diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index 36cfc58f45f..d98f2211a29 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -50,9 +50,10 @@ import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb' import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { useFileListWidth } from '../composables/useFileListWidth.ts' -import { useNavigation } from '../composables/useNavigation.ts' +import { useViews } from '../composables/useViews.ts' import logger from '../logger.ts' import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' +import { useActiveStore } from '../store/active.ts' import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' @@ -76,22 +77,24 @@ export default defineComponent({ }, setup() { - const draggingStore = useDragAndDropStore() + const activeStore = useActiveStore() const filesStore = useFilesStore() const pathsStore = usePathsStore() + const draggingStore = useDragAndDropStore() const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() + const fileListWidth = useFileListWidth() - const { currentView, views } = useNavigation() + const views = useViews() return { + activeStore, draggingStore, filesStore, pathsStore, selectionStore, uploaderStore, - currentView, fileListWidth, views, } @@ -134,7 +137,7 @@ export default defineComponent({ // used to show the views icon for the first breadcrumb viewIcon(): string { - return this.currentView?.icon ?? HomeSvg + return this.activeStore.activeView?.icon ?? HomeSvg }, selectedFiles() { @@ -152,12 +155,12 @@ export default defineComponent({ }, getFileSourceFromPath(path: string): FileSource | null { - return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null + return (this.activeStore.activeView && this.pathsStore.getPath(this.activeStore.activeView.id, path)) ?? null }, getDirDisplayName(path: string): string { if (path === '/') { - return this.currentView?.name || t('files', 'Home') + return this.activeStore.activeView?.name || t('files', 'Home') } const source = this.getFileSourceFromPath(path) @@ -169,7 +172,7 @@ export default defineComponent({ if (dir === '/') { return { ...this.$route, - params: { view: this.currentView?.id }, + params: { view: this.activeStore.activeView?.id }, query: {}, } } @@ -233,7 +236,8 @@ export default defineComponent({ const fileTree = await dataTransferToFileTree(items) // We might not have the target directory fetched yet - const contents = await this.currentView?.getContents(path) + const controller = new AbortController() + const contents = await this.activeStore.activeView?.getContents(path, { signal: controller.signal }) const folder = contents?.folder if (!folder) { showError(this.t('files', 'Target folder does not exist any more')) @@ -275,14 +279,12 @@ export default defineComponent({ } else if (index === 0) { return t('files', 'Go to the "{dir}" directory', section) } - return null }, ariaForSection(section) { if (section?.to?.query?.dir === this.$route.query.dir) { return t('files', 'Reload current directory') } - return null }, t, diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 0be8c9c6380..8918409a54a 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -38,9 +38,9 @@ import { UploadStatus } from '@nextcloud/upload' import debounce from 'debounce' import { defineComponent } from 'vue' import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' -import { useNavigation } from '../composables/useNavigation.ts' import logger from '../logger.ts' import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts' +import { useActiveStore } from '../store/active.ts' export default defineComponent({ name: 'DragAndDropNotice', @@ -57,10 +57,10 @@ export default defineComponent({ }, setup() { - const { currentView } = useNavigation() + const activeStore = useActiveStore() return { - currentView, + activeStore, } }, @@ -177,7 +177,8 @@ export default defineComponent({ const fileTree = await dataTransferToFileTree(items) // We might not have the target directory fetched yet - const contents = await this.currentView?.getContents(this.currentFolder.path) + const controller = new AbortController() + const contents = await this.activeStore.activeView?.getContents(this.currentFolder.path, { signal: controller.signal }) const folder = contents?.folder if (!folder) { showError(this.t('files', 'Target folder does not exist any more')) @@ -211,13 +212,7 @@ export default defineComponent({ ...this.$route.params, fileid: String(lastUpload.response!.headers['oc-fileid']), }, - - query: { - ...this.$route.query, - }, } - // Remove open file from query - delete location.query?.openfile this.$router.push(location) } diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index f4ee7ed90d0..1dd03dbcb29 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -75,14 +75,15 @@ import type { Node } from '@nextcloud/files' import type { PropType } from 'vue' import type { FileSource } from '../types.ts' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' -import { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import logger from '../logger.ts' import filesSortingMixin from '../mixins/filesSorting.ts' +import { useActiveStore } from '../store/active.ts' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' @@ -126,15 +127,17 @@ export default defineComponent({ }, setup() { + const activeStore = useActiveStore() const filesStore = useFilesStore() const selectionStore = useSelectionStore() - const { currentView } = useNavigation() + const { directory } = useRouteParameters() return { + activeStore, filesStore, selectionStore, - currentView, + directory, } }, @@ -144,12 +147,12 @@ export default defineComponent({ if (this.filesListWidth < 512) { return [] } - return this.currentView?.columns || [] + return this.activeStore.activeView?.columns || [] }, dir() { // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + return this.directory.replace(/^(.+)\/$/, '$1') }, selectAllBind() { @@ -195,11 +198,10 @@ export default defineComponent({ }, methods: { - ariaSortForMode(mode: string): 'ascending' | 'descending' | null { + ariaSortForMode(mode: string): 'ascending' | 'descending' | undefined { if (this.sortingMode === mode) { return this.isAscSorting ? 'ascending' : 'descending' } - return null }, classForColumn(column) { @@ -207,7 +209,7 @@ export default defineComponent({ 'files-list__column': true, 'files-list__column--sortable': !!column.sort, 'files-list__row-column-custom': true, - [`files-list__row-${this.currentView?.id}-${column.id}`]: true, + [`files-list__row-${this.activeStore.activeView?.id}-${column.id}`]: true, } }, diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue index 618e6c99093..f6c3a1e2828 100644 --- a/apps/files/src/components/FilesNavigationItem.vue +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -45,7 +45,7 @@ import { defineComponent } from 'vue' import { Fragment } from 'vue-frag' import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' -import { useNavigation } from '../composables/useNavigation.js' +import { useActiveStore } from '../store/active.js' import { useViewConfigStore } from '../store/viewConfig.js' const maxLevel = 7 // Limit nesting to not exceed max call stack size @@ -77,10 +77,10 @@ export default defineComponent({ }, setup() { - const { currentView } = useNavigation() + const activeStore = useActiveStore() const viewConfigStore = useViewConfigStore() return { - currentView, + activeStore, viewConfigStore, } }, @@ -89,7 +89,7 @@ export default defineComponent({ currentViews(): View[] { if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) - .filter((view) => view.params?.dir.startsWith(this.parent.params?.dir)) + .filter((view) => this.parent.params && view.params?.dir.startsWith(this.parent.params.dir)) } return this.filterVisible(this.views[this.parent.id] ?? []) }, @@ -106,7 +106,7 @@ export default defineComponent({ methods: { filterVisible(views: View[]) { - return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + return views.filter(({ id, hidden }) => id === this.activeStore.activeView?.id || hidden !== true) }, hasChildViews(view: View): boolean { diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue index 9309c5d7ea8..0f66d55af96 100644 --- a/apps/files/src/components/FilesNavigationSearch.vue +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -12,11 +12,11 @@ import NcActions from '@nextcloud/vue/components/NcActions' import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' -import { useNavigation } from '../composables/useNavigation.ts' +import { useActiveStore } from '../store/active.ts' import { useSearchStore } from '../store/search.ts' import { VIEW_ID } from '../views/search.ts' -const { currentView } = useNavigation(true) +const activeStore = useActiveStore() const searchStore = useSearchStore() /** @@ -49,7 +49,7 @@ onBeforeNavigation((to, from, next) => { * Are we currently on the search view. * Needed to disable the action menu (we cannot change the search mode there) */ -const isSearchView = computed(() => currentView.value.id === VIEW_ID) +const isSearchView = computed(() => activeStore.activeView?.id === VIEW_ID) /** * Different searchbox label depending if filtering or searching diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts deleted file mode 100644 index 743fa6669e0..00000000000 --- a/apps/files/src/composables/useNavigation.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { Navigation, View } from '@nextcloud/files' - -import * as nextcloudFiles from '@nextcloud/files' -import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { defineComponent } from 'vue' -import { useNavigation } from './useNavigation.ts' - -// Just a wrapper so we can test the composable -const TestComponent = defineComponent({ - template: '
', - setup() { - const { currentView, views } = useNavigation() - return { - currentView, - views, - } - }, -}) - -describe('Composables: useNavigation', () => { - const spy = vi.spyOn(nextcloudFiles, 'getNavigation') - let navigation: Navigation - - describe('currentView', () => { - beforeEach(() => { - navigation = new nextcloudFiles.Navigation() - spy.mockImplementation(() => navigation) - }) - - it('should return null without active navigation', () => { - const wrapper = mount(TestComponent) - expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(null) - }) - - it('should return already active navigation', async () => { - const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) - navigation.register(view) - navigation.setActive(view.id) - // Now the navigation is already set it should take the active navigation - const wrapper = mount(TestComponent) - expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(view) - }) - - it('should be reactive on updating active navigation', async () => { - const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) - navigation.register(view) - const wrapper = mount(TestComponent) - - // no active navigation - expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(null) - - navigation.setActive(view.id) - // Now the navigation is set it should take the active navigation - expect((wrapper.vm as unknown as { currentView: View | null }).currentView).toBe(view) - }) - }) - - describe('views', () => { - beforeEach(() => { - navigation = new nextcloudFiles.Navigation() - spy.mockImplementation(() => navigation) - }) - - it('should return empty array without registered views', () => { - const wrapper = mount(TestComponent) - expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([]) - }) - - it('should return already registered views', () => { - const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) - // register before mount - navigation.register(view) - // now mount and check that the view is listed - const wrapper = mount(TestComponent) - expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view]) - }) - - it('should be reactive on registering new views', () => { - const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) - const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-2', name: 'My View 2', order: 1 }) - - // register before mount - navigation.register(view) - // now mount and check that the view is listed - const wrapper = mount(TestComponent) - expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view]) - - // now register view 2 and check it is reactivly added - navigation.register(view2) - expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view, view2]) - }) - }) -}) diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts deleted file mode 100644 index b4a8f723da5..00000000000 --- a/apps/files/src/composables/useNavigation.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { View } from '@nextcloud/files' -import type { ShallowRef } from 'vue' - -import { subscribe } from '@nextcloud/event-bus' -import { getNavigation } from '@nextcloud/files' -import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue' - -/** - * Composable to get the currently active files view from the files navigation - * - * @param _loaded If set enforce a current view is loaded - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function useNavigation(_loaded?: T) { - type MaybeView = T extends true ? View : (View | null) - const navigation = getNavigation() - const views: ShallowRef = shallowRef(navigation.views) - - /** @deprecated use activeStore.activeView instead */ - const currentView: ShallowRef = shallowRef(navigation.active as MaybeView) - - /** - * Event listener to update the `currentView` - * - * @param event The update event - */ - function onUpdateActive(event: CustomEvent) { - currentView.value = event.detail as MaybeView - } - - /** - * Event listener to update all registered views - */ - function onUpdateViews() { - views.value = navigation.views - triggerRef(views) - } - - onMounted(() => { - navigation.addEventListener('update', onUpdateViews) - navigation.addEventListener('updateActive', onUpdateActive) - subscribe('files:navigation:updated', onUpdateViews) - }) - onUnmounted(() => { - navigation.removeEventListener('update', onUpdateViews) - navigation.removeEventListener('updateActive', onUpdateActive) - }) - - return { - currentView, - views, - } -} diff --git a/apps/files/src/composables/useViews.spec.ts b/apps/files/src/composables/useViews.spec.ts new file mode 100644 index 00000000000..9ab4d126c13 --- /dev/null +++ b/apps/files/src/composables/useViews.spec.ts @@ -0,0 +1,63 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Navigation, View } from '@nextcloud/files' + +import * as nextcloudFiles from '@nextcloud/files' +import { enableAutoDestroy, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { useViews } from './useViews.ts' + +// Just a wrapper so we can test the composable +const TestComponent = defineComponent({ + template: '
', + setup() { + return { + views: useViews(), + } + }, +}) + +enableAutoDestroy(afterEach) + +describe('Composables: useViews', () => { + const spy = vi.spyOn(nextcloudFiles, 'getNavigation') + let navigation: Navigation + + beforeEach(() => { + navigation = new nextcloudFiles.Navigation() + spy.mockImplementation(() => navigation) + }) + + it('should return empty array without registered views', () => { + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([]) + }) + + it('should return already registered views', () => { + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view]) + }) + + it('should be reactive on registering new views', () => { + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-1', name: 'My View 1', order: 0 }) + const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '', id: 'view-2', name: 'My View 2', order: 1 }) + + // register before mount + navigation.register(view) + // now mount and check that the view is listed + const wrapper = mount(TestComponent) + expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view]) + + // now register view 2 and check it is reactively added + navigation.register(view2) + expect((wrapper.vm as unknown as { views: View[] }).views).toStrictEqual([view, view2]) + }) +}) diff --git a/apps/files/src/composables/useViews.ts b/apps/files/src/composables/useViews.ts new file mode 100644 index 00000000000..8f5e231f488 --- /dev/null +++ b/apps/files/src/composables/useViews.ts @@ -0,0 +1,36 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getNavigation } from '@nextcloud/files' +import { createSharedComposable } from '@vueuse/core' +import { onUnmounted, shallowRef, triggerRef } from 'vue' + +/** + * Composable to get the currently available views + */ +export const useViews = createSharedComposable(useInternalViews) + +/** + * Composable to get the currently available views + */ +export function useInternalViews() { + const navigation = getNavigation() + const views = shallowRef(navigation.views) + + /** + * Event listener to update all registered views + */ + function onUpdateViews() { + views.value = navigation.views + triggerRef(views) + } + + navigation.addEventListener('update', onUpdateViews) + onUnmounted(() => { + navigation.removeEventListener('update', onUpdateViews) + }) + + return views +} diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts index d75dcf3f221..92fc91197af 100644 --- a/apps/files/src/mixins/filesSorting.ts +++ b/apps/files/src/mixins/filesSorting.ts @@ -1,18 +1,19 @@ -import { mapState } from 'pinia' -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import { mapState } from 'pinia' import Vue from 'vue' -import { useNavigation } from '../composables/useNavigation.ts' +import { useActiveStore } from '../store/active.ts' import { useViewConfigStore } from '../store/viewConfig.ts' export default Vue.extend({ setup() { - const { currentView } = useNavigation() + const activeStore = useActiveStore() return { - currentView, + activeStore, } }, @@ -23,8 +24,8 @@ export default Vue.extend({ * Get the sorting mode for the current view */ sortingMode(): string { - return this.getConfig(this.currentView.id)?.sorting_mode as string - || this.currentView?.defaultSortKey + return this.getConfig(this.activeStore.activeView?.id)?.sorting_mode as string + || this.activeStore.activeView?.defaultSortKey || 'basename' }, @@ -32,7 +33,7 @@ export default Vue.extend({ * Get the sorting direction for the current view */ isAscSorting(): boolean { - const sortingDirection = this.getConfig(this.currentView.id)?.sorting_direction + const sortingDirection = this.getConfig(this.activeStore.activeView?.id)?.sorting_direction return sortingDirection !== 'desc' }, }, @@ -41,11 +42,11 @@ export default Vue.extend({ toggleSortBy(key: string) { // If we're already sorting by this key, flip the direction if (this.sortingMode === key) { - this.toggleSortingDirection(this.currentView.id) + this.toggleSortingDirection(this.activeStore.activeView?.id) return } // else sort ASC by this new key - this.setSortingBy(key, this.currentView.id) + this.setSortingBy(key, this.activeStore.activeView?.id) }, }, }) diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index 8ea977bd294..9f7b311557f 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -1,9 +1,9 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Folder, Node } from '@nextcloud/files' +import type { IFolder, INode } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' import type { RootDirectory } from './DropServiceUtils.ts' @@ -96,7 +96,7 @@ export async function dataTransferToFileTree(items: DataTransferItem[]): Promise * @param destination - The destination folder * @param contents - The contents of the destination folder */ -export async function onDropExternalFiles(root: RootDirectory, destination: Folder, contents: Node[]): Promise { +export async function onDropExternalFiles(root: RootDirectory, destination: IFolder, contents: INode[]): Promise { const uploader = getUploader() // Check for conflicts on root elements @@ -172,13 +172,14 @@ export async function onDropExternalFiles(root: RootDirectory, destination: Fold } /** + * Handle dropping internal files * - * @param nodes - * @param destination - * @param contents - * @param isCopy + * @param nodes - The nodes being dropped + * @param destination - The destination folder + * @param contents - The contents of the destination folder + * @param isCopy - Whether the operation is a copy */ -export async function onDropInternalFiles(nodes: Node[], destination: Folder, contents: Node[], isCopy = false) { +export async function onDropInternalFiles(nodes: INode[], destination: IFolder, contents: INode[], isCopy = false) { const queue = [] as Promise[] // Check for conflicts on root elements diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts index 5c41a08aaea..3b04af08d92 100644 --- a/apps/files/src/services/DropServiceUtils.ts +++ b/apps/files/src/services/DropServiceUtils.ts @@ -1,14 +1,15 @@ -import type { Folder, Node } from '@nextcloud/files' -/** +/*! * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import type { IFolder, INode } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed } from 'webdav' import { showInfo, showWarning } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' import { getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import { openConflictPicker } from '@nextcloud/upload' import logger from '../logger.ts' @@ -131,8 +132,9 @@ function readDirectory(directory: FileSystemDirectoryEntry): Promise(files: Array, destination: Folder, contents: Node[]): Promise { +export async function resolveConflict(files: Array, destination: IFolder, contents: INode[]): Promise { try { // List all conflicting files - const conflicts = files.filter((file: File | Node) => { - return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename)) - }).filter(Boolean) as (File | Node)[] + const conflicts = files.filter((file: File | INode) => { + return contents.find((node: INode) => node.basename === (file instanceof File ? file.name : file.basename)) + }).filter(Boolean) as (File | INode)[] // List of incoming files that are NOT in conflict - const uploads = files.filter((file: File | Node) => { + const uploads = files.filter((file: File | INode) => { return !conflicts.includes(file) }) diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 23e93a623fb..608083ef82e 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -169,7 +169,7 @@ import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { Folder, getFileListActions, getSidebar, Permission, sortNodes } from '@nextcloud/files' +import { Folder, getFileListActions, Permission, sortNodes } from '@nextcloud/files' import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' @@ -178,7 +178,7 @@ import { ShareType } from '@nextcloud/sharing' import { UploadPicker, UploadStatus } from '@nextcloud/upload' import { useThrottleFn } from '@vueuse/core' import { normalize, relative } from 'path' -import { defineComponent } from 'vue' +import { computed, defineComponent } from 'vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' import NcAppContent from '@nextcloud/vue/components/NcAppContent' @@ -196,7 +196,6 @@ import BreadCrumbs from '../components/BreadCrumbs.vue' import DragAndDropNotice from '../components/DragAndDropNotice.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' import { useFileListWidth } from '../composables/useFileListWidth.ts' -import { useNavigation } from '../composables/useNavigation.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import logger from '../logger.ts' import filesSortingMixin from '../mixins/filesSorting.ts' @@ -205,6 +204,7 @@ import { useFilesStore } from '../store/files.ts' import { useFiltersStore } from '../store/filters.ts' import { usePathsStore } from '../store/paths.ts' import { useSelectionStore } from '../store/selection.ts' +import { useSidebarStore } from '../store/sidebar.ts' import { useUploaderStore } from '../store/uploader.ts' import { useUserConfigStore } from '../store/userconfig.ts' import { useViewConfigStore } from '../store/viewConfig.ts' @@ -249,12 +249,7 @@ export default defineComponent({ }, setup() { - const sidebar = getSidebar() - - const { currentView } = useNavigation() - const { directory, fileId } = useRouteParameters() - const fileListWidth = useFileListWidth() - + const sidebar = useSidebarStore() const activeStore = useActiveStore() const filesStore = useFilesStore() const filtersStore = useFiltersStore() @@ -264,9 +259,14 @@ export default defineComponent({ const userConfigStore = useUserConfigStore() const viewConfigStore = useViewConfigStore() + const fileListWidth = useFileListWidth() + const { directory, fileId } = useRouteParameters() + const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true) const forbiddenCharacters = loadState('files', 'forbiddenCharacters', []) + const currentView = computed(() => activeStore.activeView) + return { currentView, directory, @@ -274,6 +274,7 @@ export default defineComponent({ fileListWidth, t, + sidebar, activeStore, filesStore, filtersStore, @@ -284,7 +285,6 @@ export default defineComponent({ viewConfigStore, // non reactive data - sidebar, enableGridView, forbiddenCharacters, ShareType, @@ -319,7 +319,8 @@ export default defineComponent({ } // If not found in the files store (cache) // use the current view to fetch the content for the requested path - return (await view.getContents(normalizedPath)).contents + const controller = new AbortController() + return (await view.getContents(normalizedPath, { signal: controller.signal })).contents } }, diff --git a/apps/files/src/views/FilesNavigation.vue b/apps/files/src/views/FilesNavigation.vue index 603a987adce..1d82b587795 100644 --- a/apps/files/src/views/FilesNavigation.vue +++ b/apps/files/src/views/FilesNavigation.vue @@ -58,8 +58,9 @@ import FilesNavigationItem from '../components/FilesNavigationItem.vue' import FilesNavigationSearch from '../components/FilesNavigationSearch.vue' import NavigationQuota from '../components/NavigationQuota.vue' import FilesAppSettings from './FilesAppSettings.vue' -import { useNavigation } from '../composables/useNavigation.ts' +import { useViews } from '../composables/useViews.ts' import logger from '../logger.ts' +import { useActiveStore } from '../store/active.ts' import { useFiltersStore } from '../store/filters.ts' import { useSidebarStore } from '../store/sidebar.ts' import { useViewConfigStore } from '../store/viewConfig.ts' @@ -89,18 +90,19 @@ export default defineComponent({ setup() { const sidebar = useSidebarStore() + const activeStore = useActiveStore() const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() - const { currentView, views } = useNavigation() return { - currentView, t, - views, sidebar, + activeStore, filtersStore, viewConfigStore, + + views: useViews(), } }, @@ -138,7 +140,7 @@ export default defineComponent({ watch: { currentViewId(newView, oldView) { - if (this.currentViewId !== this.currentView?.id) { + if (this.currentViewId !== this.activeStore.activeView?.id) { // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view const view = this.views.find(({ id }) => id === this.currentViewId)! // The new view as active diff --git a/build/frontend-legacy/tsconfig.json b/build/frontend-legacy/tsconfig.json index 17d375ab2ea..c8dcfe86c46 100644 --- a/build/frontend-legacy/tsconfig.json +++ b/build/frontend-legacy/tsconfig.json @@ -3,7 +3,7 @@ "include": ["./apps/**/*.ts", "./apps/**/*.vue", "./core/**/*.ts", "./core/**/*.vue", "./*.d.ts"], "exclude": ["./**/*.cy.ts"], "compilerOptions": { - "lib": ["DOM", "ESNext"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "types": ["node", "vue", "vue-router"], "outDir": "./dist/", "target": "ESNext",