diff options
Diffstat (limited to 'apps/files/src')
-rw-r--r-- | apps/files/src/actions/openLocallyAction.ts | 139 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 46 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryPreview.vue | 12 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 6 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableFooter.vue | 8 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableHeader.vue | 12 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 26 | ||||
-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/services/Templates.js | 5 | ||||
-rw-r--r-- | apps/files/src/store/renaming.ts | 10 | ||||
-rw-r--r-- | apps/files/src/store/userconfig.ts | 1 | ||||
-rw-r--r-- | apps/files/src/types.ts | 1 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 7 | ||||
-rw-r--r-- | apps/files/src/views/Settings.vue | 5 | ||||
-rw-r--r-- | apps/files/src/views/TemplatePicker.vue | 17 |
16 files changed, 309 insertions, 109 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/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 9642c4709d8..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -49,6 +49,15 @@ :opened.sync="openedMenu" :source="source" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + :title="mime" + class="files-list__row-mime" + data-cy-files-list-row-mime + @click="openDetailsIfAvailable"> + <span>{{ mime }}</span> + </td> + <!-- Size --> <td v-if="!compact && isSizeAvailable" :style="sizeOpacity" @@ -85,9 +94,10 @@ </template> <script lang="ts"> -import { formatFileSize } from '@nextcloud/files' +import { FileType, formatFileSize } from '@nextcloud/files' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' import NcDateTime from '@nextcloud/vue/components/NcDateTime' import { useNavigation } from '../composables/useNavigation.ts' @@ -123,6 +133,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isSizeAvailable: { type: Boolean, default: false, @@ -186,6 +200,36 @@ export default defineComponent({ return this.currentView.columns || [] }, + mime() { + if (this.source.type === FileType.Folder) { + return this.t('files', 'Folder') + } + + if (!this.source.mime || this.source.mime === 'application/octet-stream') { + return t('files', 'Unknown file type') + } + + if (window.OC?.MimeTypeList?.names?.[this.source.mime]) { + return window.OC.MimeTypeList.names[this.source.mime] + } + + const baseType = this.source.mime.split('/')[0] + const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || '' + if (baseType === 'image') { + return t('files', '{ext} image', { ext }) + } + if (baseType === 'video') { + return t('files', '{ext} video', { ext }) + } + if (baseType === 'audio') { + return t('files', '{ext} audio', { ext }) + } + if (baseType === 'text') { + return t('files', '{ext} text', { ext }) + } + + return this.source.mime + }, size() { const size = this.source.size if (size === undefined || isNaN(size) || size < 0) { diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 2d5844f851f..506677b49af 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -21,6 +21,7 @@ class="files-list__row-icon-blurhash" aria-hidden="true" /> <img v-if="backgroundFailed !== true" + :key="source.fileid" ref="previewImg" alt="" class="files-list__row-icon-preview" @@ -147,6 +148,17 @@ export default defineComponent({ return null } + if (this.source.attributes['has-preview'] !== true + && this.source.mime !== undefined + && this.source.mime !== 'application/octet-stream' + ) { + const previewUrl = generateUrl('/core/mimeicon?mime={mime}', { + mime: this.source.mime, + }) + const url = new URL(window.location.origin + previewUrl) + return url.href + } + try { const previewUrl = this.source.attributes.previewUrl || (this.isPublic diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index 589073e7b9a..735490c45b3 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -356,7 +356,7 @@ export default defineComponent({ // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab // also if there is no default action use this as a fallback - const metaKeyPressed = event.ctrlKey || event.metaKey || Boolean(event.button & 4) + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 if (metaKeyPressed || !this.defaultFileAction) { // If no download permission, then we can not allow to download (direct link) the files if (isPublicShare() && !isDownloadable(this.source)) { @@ -368,7 +368,9 @@ export default defineComponent({ : generateUrl('/f/{fileId}', { fileId: this.fileid }) event.preventDefault() event.stopPropagation() - window.open(url, metaKeyPressed ? '_self' : undefined) + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') return } diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 63d692c100d..9e8cdc159ee 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -21,6 +21,10 @@ <!-- Actions --> <td class="files-list__row-actions" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + <!-- Size --> <td v-if="isSizeAvailable" class="files-list__column files-list__row-size"> @@ -60,6 +64,10 @@ export default defineComponent({ type: View, required: true, }, + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 341d5e1347d..23e631199eb 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -24,6 +24,14 @@ <!-- Actions --> <th class="files-list__row-actions" /> + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + <!-- Size --> <th v-if="isSizeAvailable" class="files-list__column files-list__row-size" @@ -83,6 +91,10 @@ export default defineComponent({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 93f567f25a4..feb4b61c53e 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -9,6 +9,7 @@ :data-sources="nodes" :grid-mode="userConfig.grid_view" :extra-props="{ + isMimeAvailable, isMtimeAvailable, isSizeAvailable, nodes, @@ -20,7 +21,9 @@ </template> <template v-if="!isNoneSelected" #header-overlay> - <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> <FilesListTableHeaderActions :current-view="currentView" :selected-nodes="selectedNodes" /> </template> @@ -39,6 +42,7 @@ <!-- Table header and sort buttons --> <FilesListTableHeader ref="thead" :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" /> @@ -48,6 +52,7 @@ <template #footer> <FilesListTableFooter :current-view="currentView" :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" @@ -65,7 +70,7 @@ import type { Location } from 'vue-router' import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { translate as t } from '@nextcloud/l10n' +import { n, t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' @@ -137,6 +142,7 @@ export default defineComponent({ selectionStore, userConfigStore, + n, t, } }, @@ -155,6 +161,16 @@ export default defineComponent({ return this.userConfigStore.userConfig }, + isMimeAvailable() { + if (!this.userConfig.show_mime_column) { + return false + } + // Hide mime column on narrow screens + if (this.fileListWidth < 1024) { + return false + } + return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream') + }, isMtimeAvailable() { // Hide mtime column on narrow screens if (this.fileListWidth < 768) { @@ -829,10 +845,12 @@ export default defineComponent({ margin-inline-end: 7px; } + .files-list__row-mime, .files-list__row-mtime, .files-list__row-size { color: var(--color-text-maxcontrast); } + .files-list__row-size { width: calc(var(--row-height) * 1.5); // Right align content/text @@ -843,6 +861,10 @@ export default defineComponent({ width: calc(var(--row-height) * 2); } + .files-list__row-mime { + width: calc(var(--row-height) * 2.5); + } + .files-list__row-column-custom { width: calc(var(--row-height) * 2); } 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/services/Templates.js b/apps/files/src/services/Templates.js index 3a0a0fdb809..d7f25846ceb 100644 --- a/apps/files/src/services/Templates.js +++ b/apps/files/src/services/Templates.js @@ -11,6 +11,11 @@ export const getTemplates = async function() { return response.data.ocs.data } +export const getTemplateFields = async function(fileId) { + const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`)) + return response.data.ocs.data +} + /** * Create a new file from a specified template * diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts index 2ac9e06ba16..fc61be3bd3b 100644 --- a/apps/files/src/store/renaming.ts +++ b/apps/files/src/store/renaming.ts @@ -14,6 +14,7 @@ import { defineStore } from 'pinia' import logger from '../logger' import Vue, { defineAsyncComponent, ref } from 'vue' import { useUserConfigStore } from './userconfig' +import { fetchNode } from '../services/WebdavClient' export const useRenamingStore = defineStore('renaming', () => { /** @@ -48,7 +49,7 @@ export const useRenamingStore = defineStore('renaming', () => { } isRenaming.value = true - const node = renamingNode.value + let node = renamingNode.value Vue.set(node, 'status', NodeStatus.LOADING) const userConfig = useUserConfigStore() @@ -86,6 +87,13 @@ export const useRenamingStore = defineStore('renaming', () => { }, }) + // Update mime type if extension changed + // as other related informations might have changed + // on the backend but it is really hard to know on the front + if (oldExtension !== newExtension) { + node = await fetchNode(node.path) + } + // Success 🎉 emit('files:node:updated', node) emit('files:node:renamed', node) diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index d84a5e0d935..d136561c2e5 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -17,6 +17,7 @@ const initialUserConfig = loadState<UserConfig>('files', 'config', { sort_favorites_first: true, sort_folders_first: true, grid_view: false, + show_mime_column: true, show_dialog_file_extension: true, }) diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index 4bf8a557f49..db3de13d4eb 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -58,6 +58,7 @@ export interface UserConfig { sort_favorites_first: boolean sort_folders_first: boolean grid_view: boolean + show_mime_column: boolean } export interface UserConfigStore { userConfig: UserConfig diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 0aa3da144c2..60791a2b527 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -483,13 +483,6 @@ export default defineComponent({ watch: { /** - * Update the window title to match the page heading - */ - pageHeading() { - document.title = `${this.pageHeading} - ${getCapabilities().theming?.productName ?? 'Nextcloud'}` - }, - - /** * Handle rendering the custom empty view * @param show The current state if the custom empty view should be rendered */ diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index 872b8a8e6d3..a9967fdbef9 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -24,6 +24,11 @@ @update:checked="setConfig('show_hidden', $event)"> {{ t('files', 'Show hidden files') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column" + :checked="userConfig.show_mime_column" + @update:checked="setConfig('show_mime_column', $event)"> + {{ t('files', 'Show file type column') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews" :checked="userConfig.crop_image_previews" @update:checked="setConfig('crop_image_previews', $event)"> diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 15286a220e9..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -57,7 +57,7 @@ import { translate as t } from '@nextcloud/l10n' import { generateRemoteUrl } from '@nextcloud/router' import { normalize, extname, join } from 'path' import { defineComponent } from 'vue' -import { createFromTemplate, getTemplates } from '../services/Templates.js' +import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcModal from '@nextcloud/vue/components/NcModal' @@ -215,7 +215,7 @@ export default defineComponent({ } }, - async createFile(templateFields) { + async createFile(templateFields = []) { const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/' // If the file doesn't have an extension, add the default one @@ -274,9 +274,18 @@ export default defineComponent({ }, async onSubmit() { - if (this.selectedTemplate?.fields?.length > 0) { + const fileId = this.selectedTemplate?.fileid + + // Only request field extraction if there is a valid template + // selected and it's not the blank template + let fields = [] + if (fileId && fileId !== this.emptyTemplate.fileid) { + fields = await getTemplateFields(fileId) + } + + if (fields.length > 0) { spawnDialog(TemplateFiller, { - fields: this.selectedTemplate.fields, + fields, onSubmit: this.createFile, }) } else { |