aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/composables
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/composables')
-rw-r--r--apps/files/src/composables/useBeforeNavigation.ts20
-rw-r--r--apps/files/src/composables/useFileListHeaders.spec.ts41
-rw-r--r--apps/files/src/composables/useFileListHeaders.ts19
-rw-r--r--apps/files/src/composables/useFileListWidth.cy.ts56
-rw-r--r--apps/files/src/composables/useFileListWidth.ts50
-rw-r--r--apps/files/src/composables/useHotKeys.spec.ts213
-rw-r--r--apps/files/src/composables/useHotKeys.ts86
-rw-r--r--apps/files/src/composables/useNavigation.spec.ts106
-rw-r--r--apps/files/src/composables/useNavigation.ts53
-rw-r--r--apps/files/src/composables/useRouteParameters.ts58
10 files changed, 702 insertions, 0 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
new file mode 100644
index 00000000000..b9eb671a181
--- /dev/null
+++ b/apps/files/src/composables/useNavigation.spec.ts
@@ -0,0 +1,106 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Navigation, View } from '@nextcloud/files'
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+
+import { useNavigation } from './useNavigation'
+import * as nextcloudFiles from '@nextcloud/files'
+
+// Just a wrapper so we can test the composable
+const TestComponent = defineComponent({
+ template: '<div></div>',
+ setup() {
+ const { currentView, views } = useNavigation()
+ return {
+ currentView,
+ views,
+ }
+ },
+})
+
+describe('Composables: useNavigation', () => {
+ const spy = vi.spyOn(nextcloudFiles, 'getNavigation')
+ let navigation: Navigation
+
+ describe('currentView', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line import/namespace
+ 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 () => {
+ // 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
+ 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 () => {
+ // 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)
+
+ // no active navigation
+ expect((wrapper.vm as unknown as { currentView: View | null}).currentView).toBe(null)
+
+ navigation.setActive(view)
+ // 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(() => {
+ // eslint-disable-next-line import/namespace
+ 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', () => {
+ // 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
+ const wrapper = mount(TestComponent)
+ expect((wrapper.vm as unknown as { views: View[]}).views).toStrictEqual([view])
+ })
+
+ it('should be reactive on registering new views', () => {
+ // 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)
+ // 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
new file mode 100644
index 00000000000..2a6f22a1232
--- /dev/null
+++ b/apps/files/src/composables/useNavigation.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 { getNavigation } from '@nextcloud/files'
+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
+ */
+// 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<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 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/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,
+ }
+}