diff options
Diffstat (limited to 'apps/files/src/views')
-rw-r--r-- | apps/files/src/views/DialogConfirmFileExtension.cy.ts | 161 | ||||
-rw-r--r-- | apps/files/src/views/DialogConfirmFileExtension.vue | 92 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 281 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.cy.ts | 28 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 38 | ||||
-rw-r--r-- | apps/files/src/views/SearchEmptyView.vue | 53 | ||||
-rw-r--r-- | apps/files/src/views/Settings.vue | 110 | ||||
-rw-r--r-- | apps/files/src/views/Sidebar.vue | 43 | ||||
-rw-r--r-- | apps/files/src/views/TemplatePicker.vue | 21 | ||||
-rw-r--r-- | apps/files/src/views/favorites.spec.ts | 27 | ||||
-rw-r--r-- | apps/files/src/views/favorites.ts | 14 | ||||
-rw-r--r-- | apps/files/src/views/files.ts | 54 | ||||
-rw-r--r-- | apps/files/src/views/folderTree.ts | 4 | ||||
-rw-r--r-- | apps/files/src/views/personal-files.ts | 26 | ||||
-rw-r--r-- | apps/files/src/views/search.ts | 51 |
15 files changed, 801 insertions, 202 deletions
diff --git a/apps/files/src/views/DialogConfirmFileExtension.cy.ts b/apps/files/src/views/DialogConfirmFileExtension.cy.ts new file mode 100644 index 00000000000..460497dd91f --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.cy.ts @@ -0,0 +1,161 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createTestingPinia } from '@pinia/testing' +import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue' +import { useUserConfigStore } from '../store/userconfig' + +describe('DialogConfirmFileExtension', () => { + it('renders with both extensions', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('heading') + .should('contain.text', 'Change file extension') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .should('exist') + .and('not.be.checked') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without old extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep without extension' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .should('be.visible') + }) + + it('renders without new extension', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }) + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Remove extension' }) + .should('be.visible') + }) + + it('emits correct value on keep old', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Keep .old' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[false]])) + }) + + it('emits correct value on use new', () => { + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [createTestingPinia({ + createSpy: cy.spy, + })], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('button', { name: 'Use .new' }) + .click() + cy.get('@component') + .its('wrapper') + .should((wrapper) => expect(wrapper.emitted('close')).to.eql([[true]])) + }) + + it('updates user config when checking the checkbox', () => { + const pinia = createTestingPinia({ + createSpy: cy.spy, + }) + + cy.mount(DialogConfirmFileExtension, { + propsData: { + oldExtension: '.old', + newExtension: '.new', + }, + global: { + plugins: [pinia], + }, + }).as('component') + + cy.findByRole('dialog') + .as('dialog') + .should('be.visible') + cy.get('@dialog') + .findByRole('checkbox', { name: /Do not show this dialog again/i }) + .check({ force: true }) + + cy.wrap(useUserConfigStore()) + .its('update') + .should('have.been.calledWith', 'show_dialog_file_extension', false) + }) +}) diff --git a/apps/files/src/views/DialogConfirmFileExtension.vue b/apps/files/src/views/DialogConfirmFileExtension.vue new file mode 100644 index 00000000000..cc1ee363f98 --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.vue @@ -0,0 +1,92 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { IDialogButton } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { computed, ref } from 'vue' +import { useUserConfigStore } from '../store/userconfig.ts' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import svgIconCancel from '@mdi/svg/svg/cancel.svg?raw' +import svgIconCheck from '@mdi/svg/svg/check.svg?raw' + +const props = defineProps<{ + oldExtension?: string + newExtension?: string +}>() + +const emit = defineEmits<{ + (e: 'close', v: boolean): void +}>() + +const userConfigStore = useUserConfigStore() +const dontShowAgain = computed({ + get: () => !userConfigStore.userConfig.show_dialog_file_extension, + set: (value: boolean) => userConfigStore.update('show_dialog_file_extension', !value), +}) + +const buttons = computed<IDialogButton[]>(() => [ + { + label: props.oldExtension + ? t('files', 'Keep {old}', { old: props.oldExtension }) + : t('files', 'Keep without extension'), + icon: svgIconCancel, + type: 'secondary', + callback: () => closeDialog(false), + }, + { + label: props.newExtension + ? t('files', 'Use {new}', { new: props.newExtension }) + : t('files', 'Remove extension'), + icon: svgIconCheck, + type: 'primary', + callback: () => closeDialog(true), + }, +]) + +/** Open state of the dialog */ +const open = ref(true) + +/** + * Close the dialog and emit the response + * @param value User selected response + */ +function closeDialog(value: boolean) { + emit('close', value) + open.value = false +} +</script> + +<template> + <NcDialog :buttons="buttons" + :open="open" + :can-close="false" + :name="t('files', 'Change file extension')" + size="small"> + <p v-if="newExtension && oldExtension"> + {{ t('files', 'Changing the file extension from "{old}" to "{new}" may render the file unreadable.', { old: oldExtension, new: newExtension }) }} + </p> + <p v-else-if="oldExtension"> + {{ t('files', 'Removing the file extension "{old}" may render the file unreadable.', { old: oldExtension }) }} + </p> + <p v-else-if="newExtension"> + {{ t('files', 'Adding the file extension "{new}" may render the file unreadable.', { new: newExtension }) }} + </p> + + <NcCheckboxRadioSwitch v-model="dontShowAgain" + class="dialog-confirm-file-extension__checkbox" + type="checkbox"> + {{ t('files', 'Do not show this dialog again.') }} + </NcCheckboxRadioSwitch> + </NcDialog> +</template> + +<style scoped> +.dialog-confirm-file-extension__checkbox { + margin-top: 1rem; +} +</style> diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 52868e459b9..3f993e24958 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -71,106 +71,123 @@ </div> <!-- Drag and drop notice --> - <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" /> - - <!-- Initial loading --> - <NcLoadingIcon v-if="loading && !isRefreshing" + <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" /> + + <!-- + Initial current view loading0. This should never happen, + views are supposed to be registered far earlier in the lifecycle. + In case the URL is bad or a view is missing, we show a loading icon. + --> + <NcLoadingIcon v-if="!currentView" class="files-list__loading-icon" :size="38" :name="t('files', 'Loading current folder')" /> - <!-- Empty content placeholder --> - <template v-else-if="!loading && isEmptyDir"> - <!-- Empty due to error --> - <NcEmptyContent v-if="error" :name="error" data-cy-files-content-error> - <template #action> - <NcButton type="secondary" @click="fetchContent"> - <template #icon> - <IconReload :size="20" /> - </template> - {{ t('files', 'Retry') }} - </NcButton> - </template> - <template #icon> - <IconAlertCircleOutline /> - </template> - </NcEmptyContent> - <!-- Custom empty view --> - <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> - <div ref="customEmptyView" /> - </div> - <!-- Default empty directory view --> - <NcEmptyContent v-else - :name="currentView?.emptyTitle || t('files', 'No files in here')" - :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" - data-cy-files-content-empty> - <template v-if="directory !== '/'" #action> - <!-- Uploader --> - <UploadPicker v-if="canUpload && !isQuotaExceeded" - allow-folders - class="files-list__header-upload-button" - :content="getContent" - :destination="currentFolder" - :forbidden-characters="forbiddenCharacters" - multiple - @failed="onUploadFail" - @uploaded="onUpload" /> - <NcButton v-else :to="toPreviousDir" type="primary"> - {{ t('files', 'Go back') }} - </NcButton> - </template> - <template #icon> - <NcIconSvgWrapper :svg="currentView.icon" /> - </template> - </NcEmptyContent> - </template> - - <!-- File list --> + <!-- File list - always mounted --> <FilesListVirtual v-else ref="filesListVirtual" :current-folder="currentFolder" :current-view="currentView" - :nodes="dirContentsSorted" /> + :nodes="dirContentsSorted" + :summary="summary"> + <template #empty> + <!-- Initial loading --> + <NcLoadingIcon v-if="loading && !isRefreshing" + class="files-list__loading-icon" + :size="38" + :name="t('files', 'Loading current folder')" /> + + <!-- Empty due to error --> + <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error> + <template #action> + <NcButton type="secondary" @click="fetchContent"> + <template #icon> + <IconReload :size="20" /> + </template> + {{ t('files', 'Retry') }} + </NcButton> + </template> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + + <!-- Custom empty view --> + <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper"> + <div ref="customEmptyView" /> + </div> + + <!-- Default empty directory view --> + <NcEmptyContent v-else + :name="currentView?.emptyTitle || t('files', 'No files in here')" + :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')" + data-cy-files-content-empty> + <template v-if="directory !== '/'" #action> + <!-- Uploader --> + <UploadPicker v-if="canUpload && !isQuotaExceeded" + allow-folders + class="files-list__header-upload-button" + :content="getContent" + :destination="currentFolder" + :forbidden-characters="forbiddenCharacters" + multiple + @failed="onUploadFail" + @uploaded="onUpload" /> + <NcButton v-else :to="toPreviousDir" type="primary"> + {{ t('files', 'Go back') }} + </NcButton> + </template> + <template #icon> + <NcIconSvgWrapper :svg="currentView?.icon" /> + </template> + </NcEmptyContent> + </template> + </FilesListVirtual> </NcAppContent> </template> <script lang="ts"> -import type { ContentsWithRoot, FileListAction, Folder, INode } from '@nextcloud/files' +import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files' import type { Upload } from '@nextcloud/upload' import type { CancelablePromise } from 'cancelable-promise' import type { ComponentPublicInstance } from 'vue' import type { Route } from 'vue-router' import type { UserConfig } from '../types.ts' +import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' import { translate as t } from '@nextcloud/l10n' -import { join, dirname, normalize } from 'path' +import { join, dirname, normalize, relative } from 'path' import { showError, showSuccess, showWarning } from '@nextcloud/dialogs' import { ShareType } from '@nextcloud/sharing' import { UploadPicker, UploadStatus } from '@nextcloud/upload' import { loadState } from '@nextcloud/initial-state' +import { useThrottleFn } from '@vueuse/core' import { defineComponent } from 'vue' +import NcAppContent from '@nextcloud/vue/components/NcAppContent' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue' import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' import IconReload from 'vue-material-design-icons/Reload.vue' import LinkIcon from 'vue-material-design-icons/Link.vue' import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue' -import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' -import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue' +import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { useNavigation } from '../composables/useNavigation.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useNavigation } from '../composables/useNavigation.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useActiveStore } from '../store/active.ts' import { useFilesStore } from '../store/files.ts' import { useFiltersStore } from '../store/filters.ts' import { usePathsStore } from '../store/paths.ts' @@ -178,12 +195,14 @@ import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import { useUserConfigStore } from '../store/userconfig.ts' import { useViewConfigStore } from '../store/viewConfig.ts' +import { humanizeWebDAVError } from '../utils/davUtils.ts' +import { getSummaryFor } from '../utils/fileUtils.ts' +import { defaultView } from '../utils/filesViews.ts' import BreadCrumbs from '../components/BreadCrumbs.vue' +import DragAndDropNotice from '../components/DragAndDropNotice.vue' import FilesListVirtual from '../components/FilesListVirtual.vue' import filesSortingMixin from '../mixins/filesSorting.ts' import logger from '../logger.ts' -import DragAndDropNotice from '../components/DragAndDropNotice.vue' -import { humanizeWebDAVError } from '../utils/davUtils.ts' const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined @@ -225,6 +244,8 @@ export default defineComponent({ const { currentView } = useNavigation() const { directory, fileId } = useRouteParameters() const fileListWidth = useFileListWidth() + + const activeStore = useActiveStore() const filesStore = useFilesStore() const filtersStore = useFiltersStore() const pathsStore = usePathsStore() @@ -243,6 +264,7 @@ export default defineComponent({ fileListWidth, t, + activeStore, filesStore, filtersStore, pathsStore, @@ -305,21 +327,23 @@ export default defineComponent({ /** * The current folder. */ - currentFolder(): Folder | undefined { - if (!this.currentView?.id) { - return - } - - if (this.directory === '/') { - return this.filesStore.getRoot(this.currentView.id) - } + currentFolder(): Folder { + // Temporary fake folder to use until we have the first valid folder + // fetched and cached. This allow us to mount the FilesListVirtual + // at all time and avoid unmount/mount and undesired rendering issues. + const dummyFolder = new Folder({ + id: 0, + source: getRemoteURL() + getRootPath(), + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.NONE, + }) - const source = this.pathsStore.getPath(this.currentView.id, this.directory) - if (source === undefined) { - return + if (!this.currentView?.id) { + return dummyFolder } - return this.filesStore.getNode(source) as Folder + return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder }, dirContents(): Node[] { @@ -331,7 +355,7 @@ export default defineComponent({ /** * The current directory contents. */ - dirContentsSorted() { + dirContentsSorted(): INode[] { if (!this.currentView) { return [] } @@ -345,12 +369,28 @@ export default defineComponent({ return this.isAscSorting ? results : results.reverse() } - return sortNodes(this.dirContentsFiltered, { + const nodes = sortNodes(this.dirContentsFiltered, { sortFavoritesFirst: this.userConfig.sort_favorites_first, sortFoldersFirst: this.userConfig.sort_folders_first, sortingMode: this.sortingMode, sortingOrder: this.isAscSorting ? 'asc' : 'desc', }) + + // TODO upstream this + if (this.currentView.id === 'files') { + nodes.sort((a, b) => { + const aa = relative(a.source, this.currentFolder!.source) === '..' + const bb = relative(b.source, this.currentFolder!.source) === '..' + if (aa && bb) { + return 0 + } else if (aa) { + return -1 + } + return 1 + }) + } + + return nodes }, /** @@ -432,10 +472,6 @@ export default defineComponent({ && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0 }, - filtersChanged() { - return this.filtersStore.filtersChanged - }, - showCustomEmptyView() { return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined }, @@ -460,16 +496,21 @@ export default defineComponent({ .toSorted((a, b) => a.order - b.order) return enabledActions }, - }, - watch: { /** - * Update the window title to match the page heading + * Using the filtered content if filters are active */ - pageHeading() { - document.title = `${this.pageHeading} - ${getCapabilities().theming?.productName ?? 'Nextcloud'}` + summary() { + const hidden = this.dirContents.length - this.dirContentsFiltered.length + return getSummaryFor(this.dirContentsFiltered, hidden) }, + debouncedFetchContent() { + return useThrottleFn(this.fetchContent, 800, true) + }, + }, + + watch: { /** * Handle rendering the custom empty view * @param show The current state if the custom empty view should be rendered @@ -484,6 +525,10 @@ export default defineComponent({ } }, + currentFolder() { + this.activeStore.activeFolder = this.currentFolder + }, + currentView(newView, oldView) { if (newView?.id === oldView?.id) { return @@ -516,32 +561,48 @@ export default defineComponent({ // Also refresh the filtered content this.filterDirContent() }, - - filtersChanged() { - if (this.filtersChanged) { - this.filterDirContent() - this.filtersStore.filtersChanged = false - } - }, }, - mounted() { - this.fetchContent() - + async mounted() { subscribe('files:node:deleted', this.onNodeDeleted) subscribe('files:node:updated', this.onUpdatedNode) // reload on settings change subscribe('files:config:updated', this.fetchContent) + + // filter content if filter were changed + subscribe('files:filters:changed', this.filterDirContent) + + subscribe('files:search:updated', this.onUpdateSearch) + + // Finally, fetch the current directory contents + await this.fetchContent() + if (this.fileId) { + // If we have a fileId, let's check if the file exists + const node = this.dirContents.find(node => node.fileid?.toString() === this.fileId?.toString()) + // If the file isn't in the current directory nor if + // the current directory is the file, we show an error + if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) { + showError(t('files', 'The file could not be found')) + } + } }, unmounted() { unsubscribe('files:node:deleted', this.onNodeDeleted) unsubscribe('files:node:updated', this.onUpdatedNode) unsubscribe('files:config:updated', this.fetchContent) + unsubscribe('files:filters:changed', this.filterDirContent) + unsubscribe('files:search:updated', this.onUpdateSearch) }, methods: { + onUpdateSearch({ query, scope }) { + if (query && scope !== 'filter') { + this.debouncedFetchContent() + } + }, + async fetchContent() { this.loading = true this.error = null @@ -549,10 +610,21 @@ export default defineComponent({ const currentView = this.currentView if (!currentView) { - logger.debug('The current view doesn\'t exists or is not ready.', { currentView }) + logger.debug('The current view does not exists or is not ready.', { currentView }) + + // If we still haven't a valid view, let's wait for the page to load + // then try again. Else redirect to the default view + window.addEventListener('DOMContentLoaded', () => { + if (!this.currentView) { + logger.warn('No current view after DOMContentLoaded, redirecting to the default view') + window.OCP.Files.Router.goToRoute(null, { view: defaultView() }) + } + }, { once: true }) return } + logger.debug('Fetching contents for directory', { dir, currentView }) + // If we have a cancellable promise ongoing, cancel it if (this.promise && 'cancel' in this.promise) { this.promise.cancel() @@ -812,6 +884,13 @@ export default defineComponent({ } } + &__before { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + margin-inline: calc(var(--default-clickable-area) + 2 * var(--app-navigation-padding)); + } + &__empty-view-wrapper { display: flex; height: 100%; diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts index a88878e2d3a..7357943ee28 100644 --- a/apps/files/src/views/Navigation.cy.ts +++ b/apps/files/src/views/Navigation.cy.ts @@ -10,7 +10,8 @@ import NavigationView from './Navigation.vue' import { useViewConfigStore } from '../store/viewConfig' import { Folder, View, getNavigation } from '@nextcloud/files' -import router from '../router/router' +import router from '../router/router.ts' +import RouterService from '../services/RouterService' const resetNavigation = () => { const nav = getNavigation() @@ -27,9 +28,18 @@ const createView = (id: string, name: string, parent?: string) => new View({ parent, }) +function mockWindow() { + window.OCP ??= {} + window.OCP.Files ??= {} + window.OCP.Files.Router = new RouterService(router) +} + describe('Navigation renders', () => { - before(() => { + before(async () => { delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) cy.mockInitialState('files', 'storageStats', { used: 1000 * 1000 * 1000, @@ -41,6 +51,7 @@ describe('Navigation renders', () => { it('renders', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -60,6 +71,7 @@ describe('Navigation API', () => { before(async () => { delete window._nc_navigation Navigation = getNavigation() + mockWindow() await router.replace({ name: 'filelist', params: { view: 'files' } }) }) @@ -152,14 +164,18 @@ describe('Navigation API', () => { }) describe('Quota rendering', () => { - before(() => { + before(async () => { delete window._nc_navigation + mockWindow() + getNavigation().register(createView('files', 'Files')) + await router.replace({ name: 'filelist', params: { view: 'files' } }) }) afterEach(() => cy.unmockInitialState()) it('Unknown quota', () => { cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -174,9 +190,11 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 1024 * 1024 * 1024, quota: -1, + total: 50 * 1024 * 1024 * 1024, }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -193,10 +211,12 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 1024 * 1024 * 1024, quota: 5 * 1024 * 1024 * 1024, + total: 5 * 1024 * 1024 * 1024, relative: 20, // percent }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, @@ -215,10 +235,12 @@ describe('Quota rendering', () => { cy.mockInitialState('files', 'storageStats', { used: 5 * 1024 * 1024 * 1024, quota: 1024 * 1024 * 1024, + total: 1024 * 1024 * 1024, relative: 500, // percent }) cy.mount(NavigationView, { + router, global: { plugins: [createTestingPinia({ createSpy: cy.spy, diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index 6e387881d9c..0f3c3647c6e 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -7,7 +7,7 @@ class="files-navigation" :aria-label="t('files', 'Files')"> <template #search> - <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" /> + <FilesNavigationSearch /> </template> <template #default> <NcAppNavigationList class="files-navigation__list" @@ -39,24 +39,24 @@ </template> <script lang="ts"> -import { getNavigation, type View } from '@nextcloud/files' +import type { View } from '@nextcloud/files' import type { ViewConfig } from '../types.ts' -import { defineComponent } from 'vue' import { emit, subscribe } from '@nextcloud/event-bus' -import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { getNavigation } from '@nextcloud/files' +import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' +import { defineComponent } from 'vue' -import IconCog from 'vue-material-design-icons/Cog.vue' -import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcAppNavigationList from '@nextcloud/vue/dist/Components/NcAppNavigationList.js' -import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js' +import IconCog from 'vue-material-design-icons/CogOutline.vue' +import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' import FilesNavigationItem from '../components/FilesNavigationItem.vue' +import FilesNavigationSearch from '../components/FilesNavigationSearch.vue' import { useNavigation } from '../composables/useNavigation' -import { useFilenameFilter } from '../composables/useFilenameFilter' import { useFiltersStore } from '../store/filters.ts' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.ts' @@ -75,12 +75,12 @@ export default defineComponent({ components: { IconCog, FilesNavigationItem, + FilesNavigationSearch, NavigationQuota, NcAppNavigation, NcAppNavigationItem, NcAppNavigationList, - NcAppNavigationSearch, SettingsModal, }, @@ -88,11 +88,9 @@ export default defineComponent({ const filtersStore = useFiltersStore() const viewConfigStore = useViewConfigStore() const { currentView, views } = useNavigation() - const { searchQuery } = useFilenameFilter() return { currentView, - searchQuery, t, views, @@ -159,14 +157,12 @@ export default defineComponent({ methods: { async loadExpandedViews() { - const viewConfigs = this.viewConfigStore.getConfigs() - const viewsToLoad: View[] = (Object.entries(viewConfigs) as Array<[string, ViewConfig]>) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .filter(([viewId, config]) => config.expanded === true) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .map(([viewId, config]) => this.views.find(view => view.id === viewId)) - .filter(Boolean) // Only registered views - .filter(view => view.loadChildViews && !view.loaded) + const viewsToLoad: View[] = (Object.entries(this.viewConfigStore.viewConfigs) as Array<[string, ViewConfig]>) + .filter(([, config]) => config.expanded === true) + .map(([viewId]) => this.views.find(view => view.id === viewId)) + // eslint-disable-next-line no-use-before-define + .filter(Boolean as unknown as ((u: unknown) => u is View)) + .filter((view) => view.loadChildViews && !view.loaded) for (const view of viewsToLoad) { await view.loadChildViews(view) } diff --git a/apps/files/src/views/SearchEmptyView.vue b/apps/files/src/views/SearchEmptyView.vue new file mode 100644 index 00000000000..904e1b0831d --- /dev/null +++ b/apps/files/src/views/SearchEmptyView.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnifyClose } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import debounce from 'debounce' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import { getPinia } from '../store/index.ts' +import { useSearchStore } from '../store/search.ts' + +const searchStore = useSearchStore(getPinia()) +const debouncedUpdate = debounce((value: string) => { + searchStore.query = value +}, 500) +</script> + +<template> + <NcEmptyContent :name="t('files', 'No search results for “{query}”', { query: searchStore.query })"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnifyClose" /> + </template> + <template #action> + <div class="search-empty-view__wrapper"> + <NcInputField class="search-empty-view__input" + :label="t('files', 'Search for files')" + :model-value="searchStore.query" + type="search" + @update:model-value="debouncedUpdate" /> + </div> + </template> + </NcEmptyContent> +</template> + +<style scoped lang="scss"> +.search-empty-view { + &__input { + flex: 0 1; + min-width: min(400px, 50vw); + } + + &__wrapper { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + } +} +</style> diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index ec012ed64ae..0838d308af9 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -9,6 +9,26 @@ @update:open="onClose"> <!-- Settings API--> <NcAppSettingsSection id="settings" :name="t('files', 'Files settings')"> + <fieldset class="files-settings__default-view" + data-cy-files-settings-setting="default_view"> + <legend> + {{ t('files', 'Default view') }} + </legend> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="files" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'All files') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :model-value="userConfig.default_view" + name="default_view" + type="radio" + value="personal" + @update:model-value="setConfig('default_view', $event)"> + {{ t('files', 'Personal files') }} + </NcCheckboxRadioSwitch> + </fieldset> <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first" :checked="userConfig.sort_favorites_first" @update:checked="setConfig('sort_favorites_first', $event)"> @@ -19,26 +39,34 @@ @update:checked="setConfig('sort_folders_first', $event)"> {{ t('files', 'Sort folders before files') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" + :checked="userConfig.folder_tree" + @update:checked="setConfig('folder_tree', $event)"> + {{ t('files', 'Enable folder tree') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + + <!-- Visual settings --> + <NcAppSettingsSection id="settings" :name="t('files', 'Visual settings')"> <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_hidden" :checked="userConfig.show_hidden" @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)"> {{ t('files', 'Crop image previews') }} </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch v-if="enableGridView" - data-cy-files-settings-setting="grid_view" - :checked="userConfig.grid_view" - @update:checked="setConfig('grid_view', $event)"> - {{ t('files', 'Enable the grid view') }} - </NcCheckboxRadioSwitch> - <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" - :checked="userConfig.folder_tree" - @update:checked="setConfig('folder_tree', $event)"> - {{ t('files', 'Enable folder tree') }} + <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_files_extensions" + :checked="userConfig.show_files_extensions" + @update:checked="setConfig('show_files_extensions', $event)"> + {{ t('files', 'Show files extensions') }} </NcCheckboxRadioSwitch> </NcAppSettingsSection> @@ -59,6 +87,7 @@ :success="webdavUrlCopied" :trailing-button-label="t('files', 'Copy to clipboard')" :value="webdavUrl" + class="webdav-url-input" readonly="readonly" type="url" @focus="$event.target.select()" @@ -72,17 +101,31 @@ :href="webdavDocs" target="_blank" rel="noreferrer noopener"> - {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗ + {{ t('files', 'Use this address to access your Files via WebDAV.') }} ↗ </a> </em> <br> - <em> + <em v-if="isTwoFactorEnabled"> <a class="setting-link" :href="appPasswordUrl"> - {{ t('files', 'If you have enabled 2FA, you must create and use a new app password by clicking here.') }} ↗ + {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗ </a> </em> </NcAppSettingsSection> + <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')"> + <em>{{ t('files', 'Prevent warning dialogs from open or reenable them.') }}</em> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_file_extension" + @update:checked="setConfig('show_dialog_file_extension', $event)"> + {{ t('files', 'Show a warning dialog when changing a file extension.') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" + :checked="userConfig.show_dialog_deletion" + @update:checked="setConfig('show_dialog_deletion', $event)"> + {{ t('files', 'Show a warning dialog when deleting files.') }} + </NcCheckboxRadioSwitch> + </NcAppSettingsSection> + <NcAppSettingsSection id="shortcuts" :name="t('files', 'Keyboard shortcuts')"> <em>{{ t('files', 'Speed up your Files experience with these quick shortcuts.') }}</em> @@ -243,19 +286,19 @@ </template> <script> -import { getCapabilities } from '@nextcloud/capabilities' -import Clipboard from 'vue-material-design-icons/ContentCopy.vue' -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' - -import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' -import { loadState } from '@nextcloud/initial-state' +import { getCapabilities } from '@nextcloud/capabilities' import { showError, showSuccess } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' -import { useHotKey } from '@nextcloud/vue/dist/Composables/useHotKey.js' +import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' + +import Clipboard from 'vue-material-design-icons/ContentCopy.vue' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcInputField from '@nextcloud/vue/components/NcInputField' import { useUserConfigStore } from '../store/userconfig.ts' import Setting from '../components/Setting.vue' @@ -299,6 +342,7 @@ export default { appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'), webdavUrlCopied: false, enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true), + isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)), } }, @@ -306,6 +350,16 @@ export default { userConfig() { return this.userConfigStore.userConfig }, + + sortedSettings() { + // Sort settings by name + return [...this.settings].sort((a, b) => { + if (a.order && b.order) { + return a.order - b.order + } + return a.name.localeCompare(b.name) + }) + }, }, created() { @@ -366,6 +420,12 @@ export default { </script> <style lang="scss" scoped> +.files-settings { + &__default-view { + margin-bottom: 0.5rem; + } +} + .setting-link:hover { text-decoration: underline; } @@ -379,4 +439,8 @@ export default { white-space: nowrap; } } + +.webdav-url-input { + margin-block-end: 0.5rem; +} </style> diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index 5418d36297b..40a16d42b42 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -38,8 +38,7 @@ <SystemTags v-if="isSystemTagsEnabled && showTagsDefault" v-show="showTags" :disabled="!fileInfo?.canEdit()" - :file-id="fileInfo.id" - @has-tags="value => showTags = value" /> + :file-id="fileInfo.id" /> <LegacyView v-for="view in views" :key="view.cid" :component="view" @@ -93,26 +92,27 @@ </template> </NcAppSidebar> </template> -<script> -import { getCurrentUser } from '@nextcloud/auth' -import { getCapabilities } from '@nextcloud/capabilities' -import { showError } from '@nextcloud/dialogs' +<script lang="ts"> +import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files' +import { defineComponent } from 'vue' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { File, Folder, davRemoteURL, davRootPath, formatFileSize } from '@nextcloud/files' import { encodePath } from '@nextcloud/paths' +import { fetchNode } from '../services/WebdavClient.ts' import { generateUrl } from '@nextcloud/router' -import { ShareType } from '@nextcloud/sharing' +import { getCapabilities } from '@nextcloud/capabilities' +import { getCurrentUser } from '@nextcloud/auth' import { mdiStar, mdiStarOutline } from '@mdi/js' -import { fetchNode } from '../services/WebdavClient.ts' -import axios from '@nextcloud/axios' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' import $ from 'jquery' +import axios from '@nextcloud/axios' -import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcUserBubble from '@nextcloud/vue/dist/Components/NcUserBubble.js' +import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' import FileInfo from '../services/FileInfo.js' import LegacyView from '../components/LegacyView.vue' @@ -120,7 +120,7 @@ import SidebarTab from '../components/SidebarTab.vue' import SystemTags from '../../../systemtags/src/components/SystemTags.vue' import logger from '../logger.ts' -export default { +export default defineComponent({ name: 'Sidebar', components: { @@ -464,7 +464,10 @@ export default { * Toggle the tags selector */ toggleTags() { - this.showTagsDefault = this.showTags = !this.showTags + // toggle + this.showTags = !this.showTags + // save the new state + this.setShowTagsDefault(this.showTags) }, /** @@ -491,7 +494,7 @@ export default { this.loading = true try { - this.node = await fetchNode({ path: this.file }) + this.node = await fetchNode(this.file) this.fileInfo = FileInfo(this.node) // adding this as fallback because other apps expect it this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') @@ -585,7 +588,7 @@ export default { this.hasLowHeight = document.documentElement.clientHeight < 1024 }, }, -} +}) </script> <style lang="scss" scoped> .app-sidebar { diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 719ebadd17c..cddacc863e1 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -57,10 +57,10 @@ 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/dist/Components/NcEmptyContent.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcModal from '@nextcloud/vue/components/NcModal' import TemplatePreview from '../components/TemplatePreview.vue' import TemplateFiller from '../components/TemplateFiller.vue' import logger from '../logger.ts' @@ -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 { diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts index 64c3df0500a..f793eb9f54c 100644 --- a/apps/files/src/views/favorites.spec.ts +++ b/apps/files/src/views/favorites.spec.ts @@ -7,6 +7,7 @@ import type { Folder as CFolder, Navigation } from '@nextcloud/files' import * as filesUtils from '@nextcloud/files' +import * as filesDavUtils from '@nextcloud/files/dav' import { CancelablePromise } from 'cancelable-promise' import { basename } from 'path' import { beforeEach, describe, expect, test, vi } from 'vitest' @@ -16,6 +17,7 @@ import { action } from '../actions/favoriteAction' import * as favoritesService from '../services/Favorites' import { registerFavoritesView } from './favorites' +// eslint-disable-next-line import/namespace const { Folder, getNavigation } = filesUtils vi.mock('@nextcloud/axios') @@ -43,7 +45,7 @@ describe('Favorites view definition', () => { test('Default empty favorite view', async () => { vi.spyOn(eventBus, 'subscribe') - vi.spyOn(filesUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) await registerFavoritesView() @@ -89,8 +91,14 @@ describe('Favorites view definition', () => { source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar', owner: 'admin', }), + new Folder({ + id: 4, + root: '/files/admin', + source: 'http://nextcloud.local/remote.php/dav/files/admin/foo/bar/yabadaba', + owner: 'admin', + }), ] - vi.spyOn(filesUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders)) + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders)) vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) await registerFavoritesView() @@ -98,9 +106,12 @@ describe('Favorites view definition', () => { const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites') // one main view and 3 children - expect(Navigation.views.length).toBe(4) + expect(Navigation.views.length).toBe(5) expect(favoritesView).toBeDefined() - expect(favoriteFoldersViews.length).toBe(3) + expect(favoriteFoldersViews.length).toBe(4) + + // Sorted by basename: bar, bar, foo + const expectedOrder = [2, 0, 1, 3] favoriteFolders.forEach((folder, index) => { const favoriteView = favoriteFoldersViews[index] @@ -108,7 +119,7 @@ describe('Favorites view definition', () => { expect(favoriteView?.id).toBeDefined() expect(favoriteView?.name).toBe(basename(folder.path)) expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/) - expect(favoriteView?.order).toBe(index) + expect(favoriteView?.order).toBe(expectedOrder[index]) expect(favoriteView?.params).toStrictEqual({ dir: folder.path, fileid: String(folder.fileid), @@ -132,7 +143,7 @@ describe('Dynamic update of favorite folders', () => { test('Add a favorite folder creates a new entry in the navigation', async () => { vi.spyOn(eventBus, 'emit') - vi.spyOn(filesUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) await registerFavoritesView() @@ -160,7 +171,7 @@ describe('Dynamic update of favorite folders', () => { test('Remove a favorite folder remove the entry from the navigation column', async () => { vi.spyOn(eventBus, 'emit') - vi.spyOn(filesUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([ + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([ new Folder({ id: 42, root: '/files/admin', @@ -211,7 +222,7 @@ describe('Dynamic update of favorite folders', () => { test('Renaming a favorite folder updates the navigation', async () => { vi.spyOn(eventBus, 'emit') - vi.spyOn(filesUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) + vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([])) vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] })) await registerFavoritesView() diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts index cadc7704e14..cac776507ef 100644 --- a/apps/files/src/views/favorites.ts +++ b/apps/files/src/views/favorites.ts @@ -4,13 +4,15 @@ */ import type { Folder, Node } from '@nextcloud/files' +import { FileType, View, getNavigation } from '@nextcloud/files' +import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n' +import { getFavoriteNodes } from '@nextcloud/files/dav' import { subscribe } from '@nextcloud/event-bus' -import { FileType, View, getFavoriteNodes, getNavigation } from '@nextcloud/files' -import { getLanguage, translate as t } from '@nextcloud/l10n' -import { client } from '../services/WebdavClient.ts' + import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import StarSvg from '@mdi/svg/svg/star.svg?raw' +import StarSvg from '@mdi/svg/svg/star-outline.svg?raw' +import { client } from '../services/WebdavClient.ts' import { getContents } from '../services/Favorites' import { hashCode } from '../utils/hashUtils' import logger from '../logger' @@ -118,7 +120,7 @@ export const registerFavoritesView = async () => { * update the order property of the existing views */ const updateAndSortViews = function() { - favoriteFolders.sort((a, b) => a.path.localeCompare(b.path, getLanguage(), { ignorePunctuation: true })) + favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' })) favoriteFolders.forEach((folder, index) => { const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path)) if (view) { @@ -176,4 +178,6 @@ export const registerFavoritesView = async () => { removePathFromFavorites(favoriteFolder.path) addToFavorites(node) } + + updateAndSortViews() } diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts index a49a13f91e1..a94aab0f14b 100644 --- a/apps/files/src/views/files.ts +++ b/apps/files/src/views/files.ts @@ -2,22 +2,64 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import { getContents } from '../services/Files' +import { emit, subscribe } from '@nextcloud/event-bus' import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Files.ts' +import { useActiveStore } from '../store/active.ts' +import { defaultView } from '../utils/filesViews.ts' + +import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw' + +export const VIEW_ID = 'files' + +/** + * Register the files view to the navigation + */ +export function registerFilesView() { + // we cache the query to allow more performant search (see below in event listener) + let oldQuery = '' -export default () => { const Navigation = getNavigation() Navigation.register(new View({ - id: 'files', + id: VIEW_ID, name: t('files', 'All files'), caption: t('files', 'List of your files and folders.'), icon: FolderSvg, - order: 0, + // if this is the default view we set it at the top of the list - otherwise below it + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) + + // when the search is updated + // and we are in the files view + // and there is already a folder fetched + // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered + subscribe('files:search:updated', ({ scope, query }) => { + if (scope === 'globally') { + return + } + + if (Navigation.active?.id !== VIEW_ID) { + return + } + + // If neither the old query nor the new query is longer than the search minimum + // then we do not need to trigger a new PROPFIND / SEARCH + // so we skip unneccessary requests here + if (oldQuery.length < 3 && query.length < 3) { + return + } + + const store = useActiveStore() + if (!store.activeFolder) { + return + } + + oldQuery = query + emit('files:node:updated', store.activeFolder) + }) } diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts index c38e4721316..2ce4e501e6f 100644 --- a/apps/files/src/views/folderTree.ts +++ b/apps/files/src/views/folderTree.ts @@ -13,7 +13,7 @@ import { isSamePath } from '@nextcloud/paths' import { loadState } from '@nextcloud/initial-state' import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw' import { folderTreeId, @@ -151,7 +151,7 @@ const registerTreeRoot = () => { Navigation.register(new View({ id: folderTreeId, - name: t('files', 'All folders'), + name: t('files', 'Folder tree'), caption: t('files', 'List of your files and folders.'), icon: FolderMultipleSvg, diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts index ce175d7c5ca..241582057d1 100644 --- a/apps/files/src/views/personal-files.ts +++ b/apps/files/src/views/personal-files.ts @@ -2,24 +2,36 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' + +import { t } from '@nextcloud/l10n' import { View, getNavigation } from '@nextcloud/files' +import { getContents } from '../services/PersonalFiles.ts' +import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts' + +import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw' + +export const VIEW_ID = 'personal' -import { getContents } from '../services/PersonalFiles' -import AccountIcon from '@mdi/svg/svg/account.svg?raw' +/** + * Register the personal files view if allowed + */ +export function registerPersonalFilesView(): void { + if (!hasPersonalFilesView()) { + return + } -export default () => { const Navigation = getNavigation() Navigation.register(new View({ - id: 'personal', - name: t('files', 'Personal Files'), + id: VIEW_ID, + name: t('files', 'Personal files'), caption: t('files', 'List of your files and folders that are not shared.'), emptyTitle: t('files', 'No personal files found'), emptyCaption: t('files', 'Files that are not shared will show up here.'), icon: AccountIcon, - order: 5, + // if this is the default view we set it at the top of the list - otherwise default position of fifth + order: defaultView() === VIEW_ID ? 0 : 5, getContents, })) diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts new file mode 100644 index 00000000000..a30f732163c --- /dev/null +++ b/apps/files/src/views/search.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance' + +import { View, getNavigation } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { getContents } from '../services/Search.ts' +import { VIEW_ID as FILES_VIEW_ID } from './files.ts' +import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw' +import Vue from 'vue' + +export const VIEW_ID = 'search' + +/** + * Register the search-in-files view + */ +export function registerSearchView() { + let instance: Vue + let view: ComponentPublicInstanceConstructor + + const Navigation = getNavigation() + Navigation.register(new View({ + id: VIEW_ID, + name: t('files', 'Search'), + caption: t('files', 'Search results within your files.'), + + async emptyView(el) { + if (!view) { + view = (await import('./SearchEmptyView.vue')).default + } else { + instance.$destroy() + } + instance = new Vue(view) + instance.$mount(el) + }, + + icon: MagnifySvg, + order: 10, + + parent: FILES_VIEW_ID, + // it should be shown expanded + expanded: true, + // this view is hidden by default and only shown when active + hidden: true, + + getContents, + })) +} |