diff options
Diffstat (limited to 'apps/files/src/composables')
-rw-r--r-- | apps/files/src/composables/useBeforeNavigation.ts | 20 | ||||
-rw-r--r-- | apps/files/src/composables/useFileListHeaders.spec.ts | 41 | ||||
-rw-r--r-- | apps/files/src/composables/useFileListHeaders.ts | 19 | ||||
-rw-r--r-- | apps/files/src/composables/useFileListWidth.cy.ts | 56 | ||||
-rw-r--r-- | apps/files/src/composables/useFileListWidth.ts | 50 | ||||
-rw-r--r-- | apps/files/src/composables/useHotKeys.spec.ts | 213 | ||||
-rw-r--r-- | apps/files/src/composables/useHotKeys.ts | 86 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.spec.ts | 34 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.ts | 15 | ||||
-rw-r--r-- | apps/files/src/composables/useRouteParameters.ts | 58 |
10 files changed, 575 insertions, 17 deletions
diff --git a/apps/files/src/composables/useBeforeNavigation.ts b/apps/files/src/composables/useBeforeNavigation.ts new file mode 100644 index 00000000000..38b72e40fb3 --- /dev/null +++ b/apps/files/src/composables/useBeforeNavigation.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { NavigationGuard } from 'vue-router' + +import { onUnmounted } from 'vue' +import { useRouter } from 'vue-router/composables' + +/** + * Helper until we use Vue-Router v4 (Vue3). + * + * @param fn - The navigation guard + */ +export function onBeforeNavigation(fn: NavigationGuard) { + const router = useRouter() + const remove = router.beforeResolve(fn) + onUnmounted(remove) +} diff --git a/apps/files/src/composables/useFileListHeaders.spec.ts b/apps/files/src/composables/useFileListHeaders.spec.ts new file mode 100644 index 00000000000..c407156412b --- /dev/null +++ b/apps/files/src/composables/useFileListHeaders.spec.ts @@ -0,0 +1,41 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Header } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useFileListHeaders } from './useFileListHeaders.ts' + +const getFileListHeaders = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/files', async (originalModule) => { + return { + ...(await originalModule()), + getFileListHeaders, + } +}) + +describe('useFileListHeaders', () => { + beforeEach(() => vi.resetAllMocks()) + + it('gets the headers', () => { + const header = new Header({ id: '1', order: 5, render: vi.fn(), updated: vi.fn() }) + getFileListHeaders.mockImplementationOnce(() => [header]) + + const headers = useFileListHeaders() + expect(headers.value).toEqual([header]) + expect(getFileListHeaders).toHaveBeenCalledOnce() + }) + + it('headers are sorted', () => { + const header = new Header({ id: '1', order: 10, render: vi.fn(), updated: vi.fn() }) + const header2 = new Header({ id: '2', order: 5, render: vi.fn(), updated: vi.fn() }) + getFileListHeaders.mockImplementationOnce(() => [header, header2]) + + const headers = useFileListHeaders() + // lower order first + expect(headers.value.map(({ id }) => id)).toStrictEqual(['2', '1']) + expect(getFileListHeaders).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/files/src/composables/useFileListHeaders.ts b/apps/files/src/composables/useFileListHeaders.ts new file mode 100644 index 00000000000..b57bcbb1432 --- /dev/null +++ b/apps/files/src/composables/useFileListHeaders.ts @@ -0,0 +1,19 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Header } from '@nextcloud/files' +import type { ComputedRef } from 'vue' + +import { getFileListHeaders } from '@nextcloud/files' +import { computed, ref } from 'vue' + +/** + * Get the registered and sorted file list headers. + */ +export function useFileListHeaders(): ComputedRef<Header[]> { + const headers = ref(getFileListHeaders()) + const sorted = computed(() => [...headers.value].sort((a, b) => a.order - b.order) as Header[]) + + return sorted +} diff --git a/apps/files/src/composables/useFileListWidth.cy.ts b/apps/files/src/composables/useFileListWidth.cy.ts new file mode 100644 index 00000000000..b0d42c4a2d6 --- /dev/null +++ b/apps/files/src/composables/useFileListWidth.cy.ts @@ -0,0 +1,56 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineComponent } from 'vue' +import { useFileListWidth } from './useFileListWidth.ts' + +const ComponentMock = defineComponent({ + template: '<div id="test-component" style="width: 100%;background: white;">{{ fileListWidth }}</div>', + setup() { + return { + fileListWidth: useFileListWidth(), + } + }, +}) +const FileListMock = defineComponent({ + template: '<main id="app-content-vue" style="width: 100%;"><component-mock /></main>', + components: { + ComponentMock, + }, +}) + +describe('composable: fileListWidth', () => { + + it('Has initial value', () => { + cy.viewport(600, 400) + + cy.mount(FileListMock, {}) + cy.get('#app-content-vue') + .should('be.visible') + .and('contain.text', '600') + }) + + it('Is reactive to size change', () => { + cy.viewport(600, 400) + cy.mount(FileListMock) + cy.get('#app-content-vue').should('contain.text', '600') + + cy.viewport(800, 400) + cy.screenshot() + cy.get('#app-content-vue').should('contain.text', '800') + }) + + it('Is reactive to style changes', () => { + cy.viewport(600, 400) + cy.mount(FileListMock) + cy.get('#app-content-vue') + .should('be.visible') + .and('contain.text', '600') + .invoke('attr', 'style', 'width: 100px') + + cy.get('#app-content-vue') + .should('contain.text', '100') + }) +}) diff --git a/apps/files/src/composables/useFileListWidth.ts b/apps/files/src/composables/useFileListWidth.ts new file mode 100644 index 00000000000..621ef204836 --- /dev/null +++ b/apps/files/src/composables/useFileListWidth.ts @@ -0,0 +1,50 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Ref } from 'vue' +import { onMounted, readonly, ref } from 'vue' + +/** The element we observe */ +let element: HTMLElement | undefined + +/** The current width of the element */ +const width = ref(0) + +const observer = new ResizeObserver((elements) => { + if (elements[0].contentBoxSize) { + // use the newer `contentBoxSize` property if available + width.value = elements[0].contentBoxSize[0].inlineSize + } else { + // fall back to `contentRect` + width.value = elements[0].contentRect.width + } +}) + +/** + * Update the observed element if needed and reconfigure the observer + */ +function updateObserver() { + const el = document.querySelector<HTMLElement>('#app-content-vue') ?? document.body + if (el !== element) { + // if already observing: stop observing the old element + if (element) { + observer.unobserve(element) + } + // observe the new element if needed + observer.observe(el) + element = el + } +} + +/** + * Get the reactive width of the file list + */ +export function useFileListWidth(): Readonly<Ref<number>> { + // Update the observer when the component is mounted (e.g. because this is the files app) + onMounted(updateObserver) + // Update the observer also in setup context, so we already have an initial value + updateObserver() + + return readonly(width) +} diff --git a/apps/files/src/composables/useHotKeys.spec.ts b/apps/files/src/composables/useHotKeys.spec.ts new file mode 100644 index 00000000000..9c001e8b5ff --- /dev/null +++ b/apps/files/src/composables/useHotKeys.spec.ts @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Location } from 'vue-router' + +import { File, Folder, Permission, View } from '@nextcloud/files' +import { enableAutoDestroy, mount } from '@vue/test-utils' +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest' +import { defineComponent, nextTick } from 'vue' +import axios from '@nextcloud/axios' + +import { getPinia } from '../store/index.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useHotKeys } from './useHotKeys.ts' +import { useUserConfigStore } from '../store/userconfig.ts' + +// this is the mocked current route +const route = vi.hoisted(() => ({ + name: 'test', + params: { + fileId: 123, + }, + query: { + openFile: 'false', + dir: '/parent/dir', + }, +})) + +// mocked router +const router = vi.hoisted(() => ({ + push: vi.fn<(route: Location) => void>(), +})) + +vi.mock('../actions/sidebarAction.ts', { spy: true }) +vi.mock('../actions/deleteAction.ts', { spy: true }) +vi.mock('../actions/favoriteAction.ts', { spy: true }) +vi.mock('../actions/renameAction.ts', { spy: true }) + +vi.mock('vue-router/composables', () => ({ + useRoute: vi.fn(() => route), + useRouter: vi.fn(() => router), +})) + +let file: File +const view = { + id: 'files', + name: 'Files', +} as View + +const TestComponent = defineComponent({ + name: 'test', + setup() { + useHotKeys() + }, + template: '<div />', +}) + +describe('HotKeysService testing', () => { + const activeStore = useActiveStore(getPinia()) + + let initialState: HTMLInputElement + + enableAutoDestroy(afterEach) + + afterEach(() => { + document.body.removeChild(initialState) + }) + + beforeEach(() => { + // Make sure the router is reset before each test + router.push.mockClear() + + // Make sure the file is reset before each test + file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE }) + const files = useFilesStore(getPinia()) + files.setRoot({ service: 'files', root }) + + // Setting the view first as it reset the active node + activeStore.activeView = view + activeStore.activeNode = file + + window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) + + mount(TestComponent) + }) + + it('Pressing d should open the sidebar once', () => { + dispatchEvent({ key: 'd', code: 'KeyD' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', altKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true }) + + expect(sidebarAction.enabled).toHaveReturnedWith(true) + expect(sidebarAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing F2 should rename the file', () => { + dispatchEvent({ key: 'F2', code: 'F2' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', altKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', metaKey: true }) + + expect(renameAction.enabled).toHaveReturnedWith(true) + expect(renameAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing s should toggle favorite', () => { + vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) + dispatchEvent({ key: 's', code: 'KeyS' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', altKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', metaKey: true }) + + expect(favoriteAction.enabled).toHaveReturnedWith(true) + expect(favoriteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing Delete should delete the file', async () => { + // @ts-expect-error unit testing + vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true) + + dispatchEvent({ key: 'Delete', code: 'Delete' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'Delete', code: 'Delete', ctrlKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', altKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', shiftKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', metaKey: true }) + + expect(deleteAction.enabled).toHaveReturnedWith(true) + expect(deleteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing alt+up should go to parent directory', () => { + expect(router.push).toHaveBeenCalledTimes(0) + dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true }) + + expect(router.push).toHaveBeenCalledOnce() + expect(router.push.mock.calls[0][0].query?.dir).toBe('/parent') + }) + + it('Pressing v should toggle grid view', async () => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent({ key: 'v', code: 'KeyV' }) + expect(userConfigStore.userConfig.grid_view).toBe(true) + }) + + it.each([ + ['ctrlKey'], + ['altKey'], + // those meta keys are still triggering... + // ['shiftKey'], + // ['metaKey'] + ])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true })) + + await nextTick() + + expect(userConfigStore.userConfig.grid_view).toBe(false) + }) +}) + +/** + * Helper to dispatch the correct event. + * + * @param init - KeyboardEvent options + */ +function dispatchEvent(init: KeyboardEventInit) { + document.body.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true })) +} diff --git a/apps/files/src/composables/useHotKeys.ts b/apps/files/src/composables/useHotKeys.ts new file mode 100644 index 00000000000..ff56627b2f9 --- /dev/null +++ b/apps/files/src/composables/useHotKeys.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { dirname } from 'path' +import { useRoute, useRouter } from 'vue-router/composables' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useRouteParameters } from './useRouteParameters.ts' +import { executeAction } from '../utils/actionUtils.ts' +import logger from '../logger.ts' + +/** + * This register the hotkeys for the Files app. + * As much as possible, we try to have all the hotkeys in one place. + * Please make sure to add tests for the hotkeys after adding a new one. + */ +export function useHotKeys(): void { + const userConfigStore = useUserConfigStore() + const { directory } = useRouteParameters() + const router = useRouter() + const route = useRoute() + + // d opens the sidebar + useHotKey('d', () => executeAction(sidebarAction), { + stop: true, + prevent: true, + }) + + // F2 renames the file + useHotKey('F2', () => executeAction(renameAction), { + stop: true, + prevent: true, + }) + + // s toggle favorite + useHotKey('s', () => executeAction(favoriteAction), { + stop: true, + prevent: true, + }) + + // Delete deletes the file + useHotKey('Delete', () => executeAction(deleteAction), { + stop: true, + prevent: true, + }) + + // alt+up go to parent directory + useHotKey('ArrowUp', goToParentDir, { + stop: true, + prevent: true, + alt: true, + }) + + // v toggle grid view + useHotKey('v', toggleGridView, { + stop: true, + prevent: true, + }) + + logger.debug('Hotkeys registered') + + /** + * Use the router to go to the parent directory + */ + function goToParentDir() { + const dir = dirname(directory.value) + + logger.debug('Navigating to parent directory', { dir }) + router.push({ params: { ...route.params }, query: { ...route.query, dir } }) + } + + /** + * Toggle the grid view + */ + function toggleGridView() { + const value = userConfigStore.userConfig.grid_view + logger.debug('Toggling grid view', { old: value, new: !value }) + userConfigStore.update('grid_view', !value) + } +} diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts index 360e12660f3..b9eb671a181 100644 --- a/apps/files/src/composables/useNavigation.spec.ts +++ b/apps/files/src/composables/useNavigation.spec.ts @@ -2,13 +2,14 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { Navigation, View } from '@nextcloud/files' +import type { Navigation, View } from '@nextcloud/files' + +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import { defineComponent, nextTick } from 'vue' -import { useNavigation } from './useNavigation' +import { defineComponent } from 'vue' -import nextcloudFiles from '@nextcloud/files' +import { useNavigation } from './useNavigation' +import * as nextcloudFiles from '@nextcloud/files' // Just a wrapper so we can test the composable const TestComponent = defineComponent({ @@ -23,12 +24,13 @@ const TestComponent = defineComponent({ }) describe('Composables: useNavigation', () => { - const spy = jest.spyOn(nextcloudFiles, 'getNavigation') + const spy = vi.spyOn(nextcloudFiles, 'getNavigation') let navigation: Navigation describe('currentView', () => { beforeEach(() => { - navigation = new Navigation() + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() spy.mockImplementation(() => navigation) }) @@ -38,7 +40,8 @@ describe('Composables: useNavigation', () => { }) it('should return already active navigation', async () => { - const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) navigation.register(view) navigation.setActive(view) // Now the navigation is already set it should take the active navigation @@ -47,7 +50,8 @@ describe('Composables: useNavigation', () => { }) it('should be reactive on updating active navigation', async () => { - const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) navigation.register(view) const wrapper = mount(TestComponent) @@ -62,7 +66,8 @@ describe('Composables: useNavigation', () => { describe('views', () => { beforeEach(() => { - navigation = new Navigation() + // eslint-disable-next-line import/namespace + navigation = new nextcloudFiles.Navigation() spy.mockImplementation(() => navigation) }) @@ -72,7 +77,8 @@ describe('Composables: useNavigation', () => { }) it('should return already registered views', () => { - const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) // register before mount navigation.register(view) // now mount and check that the view is listed @@ -81,8 +87,10 @@ describe('Composables: useNavigation', () => { }) it('should be reactive on registering new views', () => { - const view = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) - const view2 = new View({ getContents: () => Promise.reject(), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 }) + // eslint-disable-next-line import/namespace + const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace + const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 }) // register before mount navigation.register(view) diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts index f410aec895f..2a6f22a1232 100644 --- a/apps/files/src/composables/useNavigation.ts +++ b/apps/files/src/composables/useNavigation.ts @@ -3,24 +3,29 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { View } from '@nextcloud/files' +import type { ShallowRef } from 'vue' import { getNavigation } from '@nextcloud/files' -import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue' +import { subscribe } from '@nextcloud/event-bus' +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 */ -export function useNavigation() { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useNavigation<T extends boolean>(_loaded?: T) { + type MaybeView = T extends true ? View : (View | null); const navigation = getNavigation() const views: ShallowRef<View[]> = shallowRef(navigation.views) - const currentView: ShallowRef<View | null> = shallowRef(navigation.active) + const currentView: ShallowRef<MaybeView> = shallowRef(navigation.active as MaybeView) /** * Event listener to update the `currentView` * @param event The update event */ function onUpdateActive(event: CustomEvent<View|null>) { - currentView.value = event.detail + currentView.value = event.detail as MaybeView } /** @@ -28,11 +33,13 @@ export function useNavigation() { */ 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) diff --git a/apps/files/src/composables/useRouteParameters.ts b/apps/files/src/composables/useRouteParameters.ts new file mode 100644 index 00000000000..dbb8ca7f081 --- /dev/null +++ b/apps/files/src/composables/useRouteParameters.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { computed } from 'vue' +import { useRoute } from 'vue-router/composables' + +/** + * Get information about the current route + */ +export function useRouteParameters() { + + const route = useRoute() + + /** + * Get the path of the current active directory + */ + const directory = computed<string>( + () => String(route.query.dir || '/') + // Remove any trailing slash but leave root slash + .replace(/^(.+)\/$/, '$1'), + ) + + /** + * Get the current fileId used on the route + */ + const fileId = computed<number | null>(() => { + const fileId = Number.parseInt(route.params.fileid ?? '0') || null + return Number.isNaN(fileId) ? null : fileId + }) + + /** + * State of `openFile` route param + */ + const openFile = computed<boolean>( + // if `openfile` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'openfile' in route.query && (typeof route.query.openfile !== 'string' || route.query.openfile.toLocaleLowerCase() !== 'false'), + ) + + const openDetails = computed<boolean>( + // if `opendetails` is set it is considered truthy, but allow to explicitly set it to 'false' + () => 'opendetails' in route.query && (typeof route.query.opendetails !== 'string' || route.query.opendetails.toLocaleLowerCase() !== 'false'), + ) + + return { + /** Path of currently open directory */ + directory, + + /** Current active fileId */ + fileId, + + /** Should the active node should be opened (`openFile` route param) */ + openFile, + + /** Should the details sidebar be shown (`openDetails` route param) */ + openDetails, + } +} |