diff options
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/actions/openLocallyAction.ts | 139 | ||||
-rw-r--r-- | apps/files/src/router/router.ts | 62 | ||||
-rw-r--r-- | apps/files/src/services/HotKeysService.spec.ts | 61 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 2 |
4 files changed, 171 insertions, 93 deletions
diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index a80cf0cbeed..986b304210c 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -13,71 +13,6 @@ import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import IconWeb from '@mdi/svg/svg/web.svg?raw' import { isPublicShare } from '@nextcloud/sharing/public' -const confirmLocalEditDialog = ( - localEditCallback: (openingLocally: boolean) => void = () => {}, -) => { - let callbackCalled = false - - return (new DialogBuilder()) - .setName(t('files', 'Open file locally')) - .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) - .setButtons([ - { - label: t('files', 'Retry and close'), - type: 'secondary', - callback: () => { - callbackCalled = true - localEditCallback(true) - }, - }, - { - label: t('files', 'Open online'), - icon: IconWeb, - type: 'primary', - callback: () => { - callbackCalled = true - localEditCallback(false) - }, - }, - ]) - .build() - .show() - .then(() => { - // Ensure the callback is called even if the dialog is dismissed in other ways - if (!callbackCalled) { - localEditCallback(false) - } - }) -} - -const attemptOpenLocalClient = async (path: string) => { - openLocalClient(path) - confirmLocalEditDialog( - (openLocally: boolean) => { - if (!openLocally) { - window.OCA.Viewer.open({ path }) - return - } - openLocalClient(path) - }, - ) -} - -const openLocalClient = async function(path: string) { - const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' - - try { - const result = await axios.post(link, { path }) - const uid = getCurrentUser()?.uid - let url = `nc://open/${uid}@` + window.location.host + encodePath(path) - url += '?token=' + result.data.ocs.data.token - - window.open(url, '_self') - } catch (error) { - showError(t('files', 'Failed to redirect to client')) - } -} - export const action = new FileAction({ id: 'edit-locally', displayName: () => t('files', 'Open locally'), @@ -99,9 +34,81 @@ export const action = new FileAction({ }, async exec(node: Node) { - attemptOpenLocalClient(node.path) + await attemptOpenLocalClient(node.path) return null }, order: 25, }) + +/** + * Try to open the path in the Nextcloud client. + * + * If this fails a dialog is shown with 3 options: + * 1. Retry: If it fails no further dialog is shown. + * 2. Open online: The viewer is used to open the file. + * 3. Close the dialog and nothing happens (abort). + * + * @param path - The path to open + */ +async function attemptOpenLocalClient(path: string) { + await openLocalClient(path) + const result = await confirmLocalEditDialog() + if (result === 'local') { + await openLocalClient(path) + } else if (result === 'online') { + window.OCA.Viewer.open({ path }) + } +} + +/** + * Try to open a file in the Nextcloud client. + * There is no way to get notified if this action was successfull. + * + * @param path - Path to open + */ +async function openLocalClient(path: string): Promise<void> { + const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' + + try { + const result = await axios.post(link, { path }) + const uid = getCurrentUser()?.uid + let url = `nc://open/${uid}@` + window.location.host + encodePath(path) + url += '?token=' + result.data.ocs.data.token + + window.open(url, '_self') + } catch (error) { + showError(t('files', 'Failed to redirect to client')) + } +} + +/** + * Open the confirmation dialog. + */ +async function confirmLocalEditDialog(): Promise<'online'|'local'|false> { + let result: 'online'|'local'|false = false + const dialog = (new DialogBuilder()) + .setName(t('files', 'Open file locally')) + .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.')) + .setButtons([ + { + label: t('files', 'Retry and close'), + type: 'secondary', + callback: () => { + result = 'local' + }, + }, + { + label: t('files', 'Open online'), + icon: IconWeb, + type: 'primary', + callback: () => { + result = 'online' + }, + }, + ]) + .build() + + await dialog.show() + return result +} diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts index 13e74c26451..00f08c38d31 100644 --- a/apps/files/src/router/router.ts +++ b/apps/files/src/router/router.ts @@ -3,10 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { RawLocation, Route } from 'vue-router' + import { generateUrl } from '@nextcloud/router' +import { relative } from 'path' import queryString from 'query-string' import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router' import Vue from 'vue' + +import { useFilesStore } from '../store/files' +import { useNavigation } from '../composables/useNavigation' +import { usePathsStore } from '../store/paths' import logger from '../logger' Vue.use(Router) @@ -68,4 +74,60 @@ const router = new Router({ }, }) +// If navigating back from a folder to a parent folder, +// we need to keep the current dir fileid so it's highlighted +// and scrolled into view. +router.beforeEach((to, from, next) => { + if (to.params?.parentIntercept) { + delete to.params.parentIntercept + next() + return + } + + const fromDir = (from.query?.dir || '/') as string + const toDir = (to.query?.dir || '/') as string + + // We are going back to a parent directory + if (relative(fromDir, toDir) === '..') { + const { currentView } = useNavigation() + const { getNode } = useFilesStore() + const { getPath } = usePathsStore() + + if (!currentView.value?.id) { + logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir }) + return next() + } + + // Get the previous parent's file id + const fromSource = getPath(currentView.value?.id, fromDir) + if (!fromSource) { + logger.error('No source found for the parent directory', { fromDir, toDir }) + return next() + } + + const fileId = getNode(fromSource)?.fileid + if (!fileId) { + logger.error('No fileid found for the parent directory', { fromDir, toDir, fromSource }) + return next() + } + + logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId }) + next({ + name: 'filelist', + query: to.query, + params: { + ...to.params, + fileid: String(fileId), + // Prevents the beforeEach from being called again + parentIntercept: 'true', + }, + // Replace the current history entry + replace: true, + }) + } + + // else, we just continue + next() +}) + export default router diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts index c732c728ce5..7bbba77b222 100644 --- a/apps/files/src/services/HotKeysService.spec.ts +++ b/apps/files/src/services/HotKeysService.spec.ts @@ -61,6 +61,7 @@ describe('HotKeysService testing', () => { activeStore.setActiveNode(file) window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } + // We only mock what needed, we do not need Files.Router.goTo or Files.Navigation window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } } initialState = document.createElement('input') @@ -73,26 +74,26 @@ describe('HotKeysService testing', () => { }) it('Pressing d should open the sidebar once', () => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD' })) + dispatchEvent({ key: 'd', code: 'KeyD' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', metaKey: true })) + 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', () => { - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2' })) + dispatchEvent({ key: 'F2', code: 'F2' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', metaKey: true })) + 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() @@ -100,29 +101,29 @@ describe('HotKeysService testing', () => { it('Pressing s should toggle favorite', () => { vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS' })) + dispatchEvent({ key: 's', code: 'KeyS' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true })) + 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 mocking private field + // @ts-expect-error unit testing vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete' })) + dispatchEvent({ key: 'Delete', code: 'Delete' }) // Modifier keys should not trigger the action - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true })) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true })) + 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() @@ -132,7 +133,7 @@ describe('HotKeysService testing', () => { expect(goToRouteMock).toHaveBeenCalledTimes(0) window.OCP.Files.Router.query = { dir: '/foo/bar' } - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', altKey: true })) + dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true }) expect(goToRouteMock).toHaveBeenCalledOnce() expect(goToRouteMock.mock.calls[0][2].dir).toBe('/foo') @@ -145,9 +146,7 @@ describe('HotKeysService testing', () => { userConfigStore.userConfig.grid_view = false expect(userConfigStore.userConfig.grid_view).toBe(false) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV' })) - await nextTick() - + dispatchEvent({ key: 'v', code: 'KeyV' }) expect(userConfigStore.userConfig.grid_view).toBe(true) }) @@ -164,9 +163,19 @@ describe('HotKeysService testing', () => { userConfigStore.userConfig.grid_view = false expect(userConfigStore.userConfig.grid_view).toBe(false) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true })) + 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/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 0aa3da144c2..b335737c4dd 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -486,7 +486,7 @@ export default defineComponent({ * Update the window title to match the page heading */ pageHeading() { - document.title = `${this.pageHeading} - ${getCapabilities().theming?.productName ?? 'Nextcloud'}` + document.title = `${this.pageHeading} - ${getCapabilities().theming?.name ?? 'Nextcloud'}` }, /** |