aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/views')
-rw-r--r--apps/files/src/views/DialogConfirmFileExtension.cy.ts161
-rw-r--r--apps/files/src/views/DialogConfirmFileExtension.vue92
-rw-r--r--apps/files/src/views/FileReferencePickerElement.vue86
-rw-r--r--apps/files/src/views/FilesList.vue909
-rw-r--r--apps/files/src/views/Navigation.cy.ts260
-rw-r--r--apps/files/src/views/Navigation.vue323
-rw-r--r--apps/files/src/views/ReferenceFileWidget.vue306
-rw-r--r--apps/files/src/views/SearchEmptyView.vue53
-rw-r--r--apps/files/src/views/Settings.vue380
-rw-r--r--apps/files/src/views/Sidebar.vue373
-rw-r--r--apps/files/src/views/TemplatePicker.vue225
-rw-r--r--apps/files/src/views/favorites.spec.ts261
-rw-r--r--apps/files/src/views/favorites.ts183
-rw-r--r--apps/files/src/views/files.ts65
-rw-r--r--apps/files/src/views/folderTree.ts176
-rw-r--r--apps/files/src/views/personal-files.ts38
-rw-r--r--apps/files/src/views/recent.ts28
-rw-r--r--apps/files/src/views/search.ts51
18 files changed, 3403 insertions, 567 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/FileReferencePickerElement.vue b/apps/files/src/views/FileReferencePickerElement.vue
new file mode 100644
index 00000000000..b4d4bc54f14
--- /dev/null
+++ b/apps/files/src/views/FileReferencePickerElement.vue
@@ -0,0 +1,86 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div :id="containerId">
+ <FilePicker v-bind="filepickerOptions" @close="onClose" />
+ </div>
+</template>
+
+<script lang="ts">
+import type { Node as NcNode } from '@nextcloud/files'
+import type { IFilePickerButton } from '@nextcloud/dialogs'
+
+import { FilePickerVue as FilePicker } from '@nextcloud/dialogs/filepicker.js'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+export default defineComponent({
+ name: 'FileReferencePickerElement',
+ components: {
+ FilePicker,
+ },
+ props: {
+ providerId: {
+ type: String,
+ required: true,
+ },
+ accessible: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ computed: {
+ containerId() {
+ return `filepicker-${Math.random().toString(36).slice(7)}`
+ },
+ filepickerOptions() {
+ return {
+ allowPickDirectory: true,
+ buttons: this.buttonFactory,
+ container: `#${this.containerId}`,
+ multiselect: false,
+ name: t('files', 'Select file or folder to link to'),
+ }
+ },
+ },
+ methods: {
+ t,
+
+ buttonFactory(selected: NcNode[]): IFilePickerButton[] {
+ const buttons = [] as IFilePickerButton[]
+ if (selected.length === 0) {
+ return []
+ }
+ const node = selected.at(0)
+ if (node.path === '/') {
+ return [] // Do not allow selecting the users root folder
+ }
+ buttons.push({
+ label: t('files', 'Choose {file}', { file: node.displayname }),
+ type: 'primary',
+ callback: this.onClose,
+ })
+ return buttons
+ },
+
+ onClose(nodes?: NcNode[]) {
+ if (nodes === undefined || nodes.length === 0) {
+ this.$emit('cancel')
+ } else {
+ this.onSubmit(nodes[0])
+ }
+ },
+
+ onSubmit(node: NcNode) {
+ const url = new URL(window.location.href)
+ url.pathname = generateUrl('/f/{fileId}', { fileId: node.fileid! })
+ url.search = ''
+ this.$emit('submit', url.href)
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
new file mode 100644
index 00000000000..f9e517e92ee
--- /dev/null
+++ b/apps/files/src/views/FilesList.vue
@@ -0,0 +1,909 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcAppContent :page-heading="pageHeading" data-cy-files-content>
+ <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }">
+ <!-- Current folder breadcrumbs -->
+ <BreadCrumbs :path="directory" @reload="fetchContent">
+ <template #actions>
+ <!-- Sharing button -->
+ <NcButton v-if="canShare && fileListWidth >= 512"
+ :aria-label="shareButtonLabel"
+ :class="{ 'files-list__header-share-button--shared': shareButtonType }"
+ :title="shareButtonLabel"
+ class="files-list__header-share-button"
+ type="tertiary"
+ @click="openSharingSidebar">
+ <template #icon>
+ <LinkIcon v-if="shareButtonType === ShareType.Link" />
+ <AccountPlusIcon v-else :size="20" />
+ </template>
+ </NcButton>
+
+ <!-- Uploader -->
+ <UploadPicker v-if="canUpload && !isQuotaExceeded && currentFolder"
+ allow-folders
+ class="files-list__header-upload-button"
+ :content="getContent"
+ :destination="currentFolder"
+ :forbidden-characters="forbiddenCharacters"
+ multiple
+ @failed="onUploadFail"
+ @uploaded="onUpload" />
+ </template>
+ </BreadCrumbs>
+
+ <!-- Secondary loading indicator -->
+ <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
+
+ <NcActions class="files-list__header-actions"
+ :inline="1"
+ type="tertiary"
+ force-name>
+ <NcActionButton v-for="action in enabledFileListActions"
+ :key="action.id"
+ :disabled="!!loadingAction"
+ :data-cy-files-list-action="action.id"
+ close-after-click
+ @click="execFileListAction(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loadingAction === action.id" :size="18" />
+ <NcIconSvgWrapper v-else-if="action.iconSvgInline !== undefined && currentView"
+ :svg="action.iconSvgInline(currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </NcActions>
+
+ <NcButton v-if="fileListWidth >= 512 && enableGridView"
+ :aria-label="gridViewButtonLabel"
+ :title="gridViewButtonLabel"
+ class="files-list__header-grid-button"
+ type="tertiary"
+ @click="toggleGridView">
+ <template #icon>
+ <ListViewIcon v-if="userConfig.grid_view" />
+ <ViewGridIcon v-else />
+ </template>
+ </NcButton>
+ </div>
+
+ <!-- Drag and drop notice -->
+ <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')" />
+
+ <!-- File list - always mounted -->
+ <FilesListVirtual v-else
+ ref="filesListVirtual"
+ :current-folder="currentFolder"
+ :current-view="currentView"
+ :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, 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 { 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, 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 ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.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'
+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'
+
+const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
+
+export default defineComponent({
+ name: 'FilesList',
+
+ components: {
+ BreadCrumbs,
+ DragAndDropNotice,
+ FilesListVirtual,
+ LinkIcon,
+ ListViewIcon,
+ NcAppContent,
+ NcActions,
+ NcActionButton,
+ NcButton,
+ NcEmptyContent,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ AccountPlusIcon,
+ UploadPicker,
+ ViewGridIcon,
+ IconAlertCircleOutline,
+ IconReload,
+ },
+
+ mixins: [
+ filesSortingMixin,
+ ],
+
+ props: {
+ isPublic: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ setup() {
+ const { currentView } = useNavigation()
+ const { directory, fileId } = useRouteParameters()
+ const fileListWidth = useFileListWidth()
+
+ const activeStore = useActiveStore()
+ const filesStore = useFilesStore()
+ const filtersStore = useFiltersStore()
+ const pathsStore = usePathsStore()
+ const selectionStore = useSelectionStore()
+ const uploaderStore = useUploaderStore()
+ const userConfigStore = useUserConfigStore()
+ const viewConfigStore = useViewConfigStore()
+
+ const enableGridView = (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true)
+ const forbiddenCharacters = loadState<string[]>('files', 'forbiddenCharacters', [])
+
+ return {
+ currentView,
+ directory,
+ fileId,
+ fileListWidth,
+ t,
+
+ activeStore,
+ filesStore,
+ filtersStore,
+ pathsStore,
+ selectionStore,
+ uploaderStore,
+ userConfigStore,
+ viewConfigStore,
+
+ // non reactive data
+ enableGridView,
+ forbiddenCharacters,
+ ShareType,
+ }
+ },
+
+ data() {
+ return {
+ loading: true,
+ loadingAction: null as string | null,
+ error: null as string | null,
+ promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
+
+ dirContentsFiltered: [] as INode[],
+ }
+ },
+
+ computed: {
+ /**
+ * Get a callback function for the uploader to fetch directory contents for conflict resolution
+ */
+ getContent() {
+ const view = this.currentView!
+ return async (path?: string) => {
+ // as the path is allowed to be undefined we need to normalize the path ('//' to '/')
+ const normalizedPath = normalize(`${this.currentFolder?.path ?? ''}/${path ?? ''}`)
+ // Try cache first
+ const nodes = this.filesStore.getNodesByPath(view.id, normalizedPath)
+ if (nodes.length > 0) {
+ return nodes
+ }
+ // If not found in the files store (cache)
+ // use the current view to fetch the content for the requested path
+ return (await view.getContents(normalizedPath)).contents
+ }
+ },
+
+ userConfig(): UserConfig {
+ return this.userConfigStore.userConfig
+ },
+
+ pageHeading(): string {
+ const title = this.currentView?.name ?? t('files', 'Files')
+
+ if (this.currentFolder === undefined || this.directory === '/') {
+ return title
+ }
+ return `${this.currentFolder.displayname} - ${title}`
+ },
+
+ /**
+ * The current folder.
+ */
+ 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,
+ })
+
+ if (!this.currentView?.id) {
+ return dummyFolder
+ }
+
+ return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
+ },
+
+ dirContents(): Node[] {
+ return (this.currentFolder?._children || [])
+ .map(this.filesStore.getNode)
+ .filter((node: Node) => !!node)
+ },
+
+ /**
+ * The current directory contents.
+ */
+ dirContentsSorted(): INode[] {
+ if (!this.currentView) {
+ return []
+ }
+
+ const customColumn = (this.currentView?.columns || [])
+ .find(column => column.id === this.sortingMode)
+
+ // Custom column must provide their own sorting methods
+ if (customColumn?.sort && typeof customColumn.sort === 'function') {
+ const results = [...this.dirContentsFiltered].sort(customColumn.sort)
+ return this.isAscSorting ? results : results.reverse()
+ }
+
+ 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
+ },
+
+ /**
+ * The current directory is empty.
+ */
+ isEmptyDir(): boolean {
+ return this.dirContents.length === 0
+ },
+
+ /**
+ * We are refreshing the current directory.
+ * But we already have a cached version of it
+ * that is not empty.
+ */
+ isRefreshing(): boolean {
+ return this.currentFolder !== undefined
+ && !this.isEmptyDir
+ && this.loading
+ },
+
+ /**
+ * Route to the previous directory.
+ */
+ toPreviousDir(): Route {
+ const dir = this.directory.split('/').slice(0, -1).join('/') || '/'
+ return { ...this.$route, query: { dir } }
+ },
+
+ shareTypesAttributes(): number[] | undefined {
+ if (!this.currentFolder?.attributes?.['share-types']) {
+ return undefined
+ }
+ return Object.values(this.currentFolder?.attributes?.['share-types'] || {}).flat() as number[]
+ },
+ shareButtonLabel() {
+ if (!this.shareTypesAttributes) {
+ return t('files', 'Share')
+ }
+
+ if (this.shareButtonType === ShareType.Link) {
+ return t('files', 'Shared by link')
+ }
+ return t('files', 'Shared')
+ },
+ shareButtonType(): ShareType | null {
+ if (!this.shareTypesAttributes) {
+ return null
+ }
+
+ // If all types are links, show the link icon
+ if (this.shareTypesAttributes.some(type => type === ShareType.Link)) {
+ return ShareType.Link
+ }
+
+ return ShareType.User
+ },
+
+ gridViewButtonLabel() {
+ return this.userConfig.grid_view
+ ? t('files', 'Switch to list view')
+ : t('files', 'Switch to grid view')
+ },
+
+ /**
+ * Check if the current folder has create permissions
+ */
+ canUpload() {
+ return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+ },
+ isQuotaExceeded() {
+ return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
+ },
+
+ /**
+ * Check if current folder has share permissions
+ */
+ canShare() {
+ return isSharingEnabled && !this.isPublic
+ && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
+ },
+
+ showCustomEmptyView() {
+ return !this.loading && this.isEmptyDir && this.currentView?.emptyView !== undefined
+ },
+
+ enabledFileListActions() {
+ if (!this.currentView || !this.currentFolder) {
+ return []
+ }
+
+ const actions = getFileListActions()
+ const enabledActions = actions
+ .filter(action => {
+ if (action.enabled === undefined) {
+ return true
+ }
+ return action.enabled(
+ this.currentView!,
+ this.dirContents,
+ this.currentFolder as Folder,
+ )
+ })
+ .toSorted((a, b) => a.order - b.order)
+ return enabledActions
+ },
+
+ /**
+ * Using the filtered content if filters are active
+ */
+ 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
+ */
+ showCustomEmptyView(show: boolean) {
+ if (show) {
+ this.$nextTick(() => {
+ const el = this.$refs.customEmptyView as HTMLDivElement
+ // We can cast here because "showCustomEmptyView" assets that current view is set
+ this.currentView!.emptyView!(el)
+ })
+ }
+ },
+
+ currentFolder() {
+ this.activeStore.activeFolder = this.currentFolder
+ },
+
+ currentView(newView, oldView) {
+ if (newView?.id === oldView?.id) {
+ return
+ }
+
+ logger.debug('View changed', { newView, oldView })
+ this.selectionStore.reset()
+ this.fetchContent()
+ },
+
+ directory(newDir, oldDir) {
+ logger.debug('Directory changed', { newDir, oldDir })
+ // TODO: preserve selection on browsing?
+ this.selectionStore.reset()
+ if (window.OCA.Files.Sidebar?.close) {
+ window.OCA.Files.Sidebar.close()
+ }
+ this.fetchContent()
+
+ // Scroll to top, force virtual scroller to re-render
+ const filesListVirtual = this.$refs?.filesListVirtual as ComponentPublicInstance<typeof FilesListVirtual> | undefined
+ if (filesListVirtual?.$el) {
+ filesListVirtual.$el.scrollTop = 0
+ }
+ },
+
+ dirContents(contents) {
+ logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
+ emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
+ // Also refresh the filtered content
+ this.filterDirContent()
+ },
+ },
+
+ 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
+ const dir = this.directory
+ const currentView = this.currentView
+
+ if (!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()
+ logger.debug('Cancelled previous ongoing fetch')
+ }
+
+ // Fetch the current dir contents
+ this.promise = currentView.getContents(dir) as Promise<ContentsWithRoot>
+ try {
+ const { folder, contents } = await this.promise
+ logger.debug('Fetched contents', { dir, folder, contents })
+
+ // Update store
+ this.filesStore.updateNodes(contents)
+
+ // Define current directory children
+ // TODO: make it more official
+ this.$set(folder, '_children', contents.map(node => node.source))
+
+ // If we're in the root dir, define the root
+ if (dir === '/') {
+ this.filesStore.setRoot({ service: currentView.id, root: folder })
+ } else {
+ // Otherwise, add the folder to the store
+ if (folder.fileid) {
+ this.filesStore.updateNodes([folder])
+ this.pathsStore.addPath({ service: currentView.id, source: folder.source, path: dir })
+ } else {
+ // If we're here, the view API messed up
+ logger.fatal('Invalid root folder returned', { dir, folder, currentView })
+ }
+ }
+
+ // Update paths store
+ const folders = contents.filter(node => node.type === 'folder')
+ folders.forEach((node) => {
+ this.pathsStore.addPath({ service: currentView.id, source: node.source, path: join(dir, node.basename) })
+ })
+ } catch (error) {
+ logger.error('Error while fetching content', { error })
+ this.error = humanizeWebDAVError(error)
+ } finally {
+ this.loading = false
+ }
+
+ },
+
+ /**
+ * Handle the node deleted event to reset open file
+ * @param node The deleted node
+ */
+ onNodeDeleted(node: Node) {
+ if (node.fileid && node.fileid === this.fileId) {
+ if (node.fileid === this.currentFolder?.fileid) {
+ // Handle the edge case that the current directory is deleted
+ // in this case we need to keep the current view but move to the parent directory
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { view: this.currentView!.id },
+ { dir: this.currentFolder?.dirname ?? '/' },
+ )
+ } else {
+ // If the currently active file is deleted we need to remove the fileid and possible the `openfile` query
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...this.$route.params, fileid: undefined },
+ { ...this.$route.query, openfile: undefined },
+ )
+ }
+ }
+ },
+
+ /**
+ * The upload manager have finished handling the queue
+ * @param {Upload} upload the uploaded data
+ */
+ onUpload(upload: Upload) {
+ // Let's only refresh the current Folder
+ // Navigating to a different folder will refresh it anyway
+ const needsRefresh = dirname(upload.source) === this.currentFolder!.source
+
+ // TODO: fetch uploaded files data only
+ // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
+ if (needsRefresh) {
+ // fetchContent will cancel the previous ongoing promise
+ this.fetchContent()
+ }
+ },
+
+ async onUploadFail(upload: Upload) {
+ const status = upload.response?.status || 0
+
+ if (upload.status === UploadStatus.CANCELLED) {
+ showWarning(t('files', 'Upload was cancelled by user'))
+ return
+ }
+
+ // Check known status codes
+ if (status === 507) {
+ showError(t('files', 'Not enough free space'))
+ return
+ } else if (status === 404 || status === 409) {
+ showError(t('files', 'Target folder does not exist any more'))
+ return
+ } else if (status === 403) {
+ showError(t('files', 'Operation is blocked by access control'))
+ return
+ }
+
+ // Else we try to parse the response error message
+ if (typeof upload.response?.data === 'string') {
+ try {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(upload.response.data, 'text/xml')
+ const message = doc.getElementsByTagName('s:message')[0]?.textContent ?? ''
+ if (message.trim() !== '') {
+ // The server message is also translated
+ showError(t('files', 'Error during upload: {message}', { message }))
+ return
+ }
+ } catch (error) {
+ logger.error('Could not parse message', { error })
+ }
+ }
+
+ // Finally, check the status code if we have one
+ if (status !== 0) {
+ showError(t('files', 'Error during upload, status code {status}', { status }))
+ return
+ }
+
+ showError(t('files', 'Unknown error during upload'))
+ },
+
+ /**
+ * Refreshes the current folder on update.
+ *
+ * @param node is the file/folder being updated.
+ */
+ onUpdatedNode(node?: Node) {
+ if (node?.fileid === this.currentFolder?.fileid) {
+ this.fetchContent()
+ }
+ },
+
+ openSharingSidebar() {
+ if (!this.currentFolder) {
+ logger.debug('No current folder found for opening sharing sidebar')
+ return
+ }
+
+ if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
+ window.OCA.Files.Sidebar.setActiveTab('sharing')
+ }
+ sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path)
+ },
+
+ toggleGridView() {
+ this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
+ },
+
+ filterDirContent() {
+ let nodes: INode[] = this.dirContents
+ for (const filter of this.filtersStore.sortedFilters) {
+ nodes = filter.filter(nodes)
+ }
+ this.dirContentsFiltered = nodes
+ },
+
+ actionDisplayName(action: FileListAction): string {
+ let displayName = action.id
+ try {
+ displayName = action.displayName(this.currentView!)
+ } catch (error) {
+ logger.error('Error while getting action display name', { action, error })
+ }
+ return displayName
+ },
+
+ async execFileListAction(action: FileListAction) {
+ this.loadingAction = action.id
+
+ const displayName = this.actionDisplayName(action)
+ try {
+ const success = await action.exec(this.source, this.dirContents, this.currentDir)
+ // If the action returns null, we stay silent
+ if (success === null || success === undefined) {
+ return
+ }
+
+ if (success) {
+ showSuccess(t('files', '{displayName}: done', { displayName }))
+ return
+ }
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } catch (error) {
+ logger.error('Error while executing action', { action, error })
+ showError(t('files', '{displayName}: failed', { displayName }))
+ } finally {
+ this.loadingAction = null
+ }
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+:global(.toast-loading-icon) {
+ // Reduce start margin (it was made for text but this is an icon)
+ margin-inline-start: -4px;
+ // 16px icon + 5px on both sides
+ min-width: 26px;
+}
+
+.app-content {
+ // Virtual list needs to be full height and is scrollable
+ display: flex;
+ overflow: hidden;
+ flex-direction: column;
+ max-height: 100%;
+ position: relative !important;
+}
+
+.files-list {
+ &__header {
+ display: flex;
+ align-items: center;
+ // Do not grow or shrink (vertically)
+ flex: 0 0;
+ max-width: 100%;
+ // Align with the navigation toggle icon
+ margin-block: var(--app-navigation-padding, 4px);
+ margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
+
+ &--public {
+ // There is no navigation toggle on public shares
+ margin-inline: 0 var(--app-navigation-padding, 4px);
+ }
+
+ >* {
+ // Do not grow or shrink (horizontally)
+ // Only the breadcrumbs shrinks
+ flex: 0 0;
+ }
+
+ &-share-button {
+ color: var(--color-text-maxcontrast) !important;
+
+ &--shared {
+ color: var(--color-main-text) !important;
+ }
+ }
+
+ &-actions {
+ min-width: fit-content !important;
+ margin-inline: calc(var(--default-grid-baseline) * 2);
+ }
+ }
+
+ &__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%;
+ }
+
+ &__refresh-icon {
+ flex: 0 0 var(--default-clickable-area);
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
+ }
+
+ &__loading-icon {
+ margin: auto;
+ }
+}
+</style>
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index c8b0f07dea1..7357943ee28 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -1,28 +1,61 @@
-import * as InitialState from '@nextcloud/initial-state'
-import * as L10n from '@nextcloud/l10n'
-import FolderSvg from '@mdi/svg/svg/folder.svg'
-import ShareSvg from '@mdi/svg/svg/share-variant.svg'
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Navigation } from '@nextcloud/files'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
+import { createTestingPinia } from '@pinia/testing'
-import NavigationService from '../services/Navigation'
import NavigationView from './Navigation.vue'
-import router from '../router/router.js'
-
-describe('Navigation renders', () => {
- const Navigation = new NavigationService()
+import { useViewConfigStore } from '../store/viewConfig'
+import { Folder, View, getNavigation } from '@nextcloud/files'
+
+import router from '../router/router.ts'
+import RouterService from '../services/RouterService'
+
+const resetNavigation = () => {
+ const nav = getNavigation()
+ ;[...nav.views].forEach(({ id }) => nav.remove(id))
+ nav.setActive(null)
+}
+
+const createView = (id: string, name: string, parent?: string) => new View({
+ id,
+ name,
+ getContents: async () => ({ folder: {} as Folder, contents: [] }),
+ icon: FolderSvg,
+ order: 1,
+ parent,
+})
- before(() => {
- cy.stub(InitialState, 'loadState')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: -1,
- })
+function mockWindow() {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router = new RouterService(router)
+}
+describe('Navigation renders', () => {
+ 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,
+ quota: -1,
+ })
})
+ after(() => cy.unmockInitialState())
+
it('renders', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -33,22 +66,31 @@ describe('Navigation renders', () => {
})
describe('Navigation API', () => {
- const Navigation = new NavigationService()
+ let Navigation: Navigation
+
+ before(async () => {
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ mockWindow()
+
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
+ })
+
+ beforeEach(() => resetNavigation())
it('Check API entries rendering', () => {
- Navigation.register({
- id: 'files',
- name: 'Files',
- getFiles: () => [],
- icon: FolderSvg,
- order: 1,
- })
+ Navigation.register(createView('files', 'Files'))
+ console.warn(Navigation.views)
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [
+ createTestingPinia({
+ createSpy: cy.spy,
+ }),
+ ],
+ },
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -58,19 +100,16 @@ describe('Navigation API', () => {
})
it('Adds a new entry and render', () => {
- Navigation.register({
- id: 'sharing',
- name: 'Sharing',
- getFiles: () => [],
- icon: ShareSvg,
- order: 2,
- })
+ Navigation.register(createView('files', 'Files'))
+ Navigation.register(createView('sharing', 'Sharing'))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -80,76 +119,67 @@ describe('Navigation API', () => {
})
it('Adds a new children, render and open menu', () => {
- Navigation.register({
- id: 'sharingin',
- name: 'Shared with me',
- getFiles: () => [],
- parent: 'sharing',
- icon: ShareSvg,
- order: 1,
- })
+ Navigation.register(createView('files', 'Files'))
+ Navigation.register(createView('sharing', 'Sharing'))
+ Navigation.register(createView('sharingin', 'Shared with me', 'sharing'))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
+ },
})
+ cy.wrap(useViewConfigStore()).as('viewConfigStore')
+
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 3)
- // Intercept collapse preference request
- cy.intercept('POST', '*/apps/files/api/v1/toggleShowFolder/*', {
- statusCode: 200,
- }).as('toggleShowFolder')
-
// Toggle the sharing entry children
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist')
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
- cy.wait('@toggleShowFolder')
+
+ // Expect store update to be called
+ cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true)
// Validate children
cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me')
+ // Toggle the sharing entry children 🇦again
+ cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
+ cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible')
+
+ // Expect store update to be called
+ cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false)
})
it('Throws when adding a duplicate entry', () => {
- expect(() => {
- Navigation.register({
- id: 'files',
- name: 'Files',
- getFiles: () => [],
- icon: FolderSvg,
- order: 1,
- })
- }).to.throw('Navigation id files is already registered')
+ Navigation.register(createView('files', 'Files'))
+ expect(() => Navigation.register(createView('files', 'Files')))
+ .to.throw('View id files is already registered')
})
})
describe('Quota rendering', () => {
- const Navigation = new NavigationService()
-
- beforeEach(() => {
- // TODO: remove when @nextcloud/l10n 2.0 is released
- // https://github.com/nextcloud/nextcloud-l10n/pull/542
- cy.stub(L10n, 'translate', (app, text, vars = {}, number) => {
- cy.log({app, text, vars, number})
- return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => {
- return vars[key]
- })
- })
+ before(async () => {
+ delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
- it('Unknown quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns(undefined)
+ afterEach(() => cy.unmockInitialState())
+ it('Unknown quota', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -157,16 +187,18 @@ describe('Quota rendering', () => {
})
it('Unlimited quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: -1,
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 1024 * 1024 * 1024,
+ quota: -1,
+ total: 50 * 1024 * 1024 * 1024,
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
@@ -176,44 +208,50 @@ describe('Quota rendering', () => {
})
it('Non-reached quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 1024 * 1024 * 1024,
- quota: 5 * 1024 * 1024 * 1024,
- relative: 20, // percent
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 1024 * 1024 * 1024,
+ quota: 5 * 1024 * 1024 * 1024,
+ total: 5 * 1024 * 1024 * 1024,
+ relative: 20, // percent
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '20')
+ cy.get('[data-cy-files-navigation-settings-quota] progress')
+ .should('exist')
+ .and('have.attr', 'value', '20')
})
it('Reached quota', () => {
- cy.stub(InitialState, 'loadState')
- .as('loadStateStats')
- .returns({
- used: 5 * 1024 * 1024 * 1024,
- quota: 1024 * 1024 * 1024,
- relative: 500, // percent
- })
+ cy.mockInitialState('files', 'storageStats', {
+ used: 5 * 1024 * 1024 * 1024,
+ quota: 1024 * 1024 * 1024,
+ total: 1024 * 1024 * 1024,
+ relative: 500, // percent
+ })
cy.mount(NavigationView, {
- propsData: {
- Navigation,
+ router,
+ global: {
+ plugins: [createTestingPinia({
+ createSpy: cy.spy,
+ })],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('be.visible')
- cy.get('[data-cy-files-navigation-settings-quota] progress').should('have.attr', 'value', '100') // progress max is 100
+ cy.get('[data-cy-files-navigation-settings-quota] progress')
+ .should('exist')
+ .and('have.attr', 'value', '100') // progress max is 100
})
})
diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue
index 040e1482e32..0f3c3647c6e 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -1,45 +1,24 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- -
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <NcAppNavigation data-cy-files-navigation>
- <template #list>
- <NcAppNavigationItem v-for="view in parentViews"
- :key="view.id"
- :allow-collapse="true"
- :data-cy-files-navigation-item="view.id"
- :icon="view.iconClass"
- :open="view.expanded"
- :pinned="view.sticky"
- :title="view.name"
- :to="generateToNavigation(view)"
- @update:open="onToggleExpand(view)">
- <NcAppNavigationItem v-for="child in childViews[view.id]"
- :key="child.id"
- :data-cy-files-navigation-item="child.id"
- :exact="true"
- :icon="child.iconClass"
- :title="child.name"
- :to="generateToNavigation(child)" />
- </NcAppNavigationItem>
+ <NcAppNavigation data-cy-files-navigation
+ class="files-navigation"
+ :aria-label="t('files', 'Files')">
+ <template #search>
+ <FilesNavigationSearch />
+ </template>
+ <template #default>
+ <NcAppNavigationList class="files-navigation__list"
+ :aria-label="t('files', 'Views')">
+ <FilesNavigationItem :views="viewMap" />
+ </NcAppNavigationList>
+
+ <!-- Settings modal-->
+ <SettingsModal :open.sync="settingsOpened"
+ data-cy-files-navigation-settings
+ @close="onSettingsClose" />
</template>
<!-- Non-scrollable navigation bottom elements -->
@@ -49,54 +28,75 @@
<NavigationQuota />
<!-- Files settings modal toggle-->
- <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')"
- :title="t('files', 'Files settings')"
+ <NcAppNavigationItem :name="t('files', 'Files settings')"
data-cy-files-navigation-settings-button
@click.prevent.stop="openSettings">
- <Cog slot="icon" :size="20" />
+ <IconCog slot="icon" :size="20" />
</NcAppNavigationItem>
</ul>
</template>
-
- <!-- Settings modal-->
- <SettingsModal :open="settingsOpened"
- data-cy-files-navigation-settings
- @close="onSettingsClose" />
</NcAppNavigation>
</template>
-<script>
-import { emit, subscribe } from '@nextcloud/event-bus'
-import { generateUrl } from '@nextcloud/router'
-import { translate } from '@nextcloud/l10n'
-
-import axios from '@nextcloud/axios'
-import Cog 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'
+<script lang="ts">
+import type { View } from '@nextcloud/files'
+import type { ViewConfig } from '../types.ts'
-import logger from '../logger.js'
-import Navigation from '../services/Navigation.ts'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+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 { useFiltersStore } from '../store/filters.ts'
+import { useViewConfigStore } from '../store/viewConfig.ts'
+import logger from '../logger.ts'
+
+const collator = Intl.Collator(
+ [getLanguage(), getCanonicalLocale()],
+ {
+ numeric: true,
+ usage: 'sort',
+ },
+)
-export default {
+export default defineComponent({
name: 'Navigation',
components: {
- Cog,
+ IconCog,
+ FilesNavigationItem,
+ FilesNavigationSearch,
+
+ NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
+ NcAppNavigationList,
SettingsModal,
- NavigationQuota,
},
- props: {
- // eslint-disable-next-line vue/prop-name-casing
- Navigation: {
- type: Navigation,
- required: true,
- },
+ setup() {
+ const filtersStore = useFiltersStore()
+ const viewConfigStore = useViewConfigStore()
+ const { currentView, views } = useNavigation()
+
+ return {
+ currentView,
+ t,
+ views,
+
+ filtersStore,
+ viewConfigStore,
+ }
},
data() {
@@ -106,134 +106,77 @@ export default {
},
computed: {
+ /**
+ * The current view ID from the route params
+ */
currentViewId() {
return this.$route?.params?.view || 'files'
},
- /** @return {Navigation} */
- currentView() {
- return this.views.find(view => view.id === this.currentViewId)
- },
-
- /** @return {Navigation[]} */
- views() {
- return this.Navigation.views
- },
-
- /** @return {Navigation[]} */
- parentViews() {
- return this.views
- // filter child views
- .filter(view => !view.parent)
- // sort views by order
- .sort((a, b) => {
- return a.order - b.order
- })
- },
-
- /** @return {Navigation[]} */
- childViews() {
+ /**
+ * Map of parent ids to views
+ */
+ viewMap(): Record<string, View[]> {
return this.views
- // filter parent views
- .filter(view => !!view.parent)
- // create a map of parents and their children
- .reduce((list, view) => {
- list[view.parent] = [...(list[view.parent] || []), view]
- // Sort children by order
- list[view.parent].sort((a, b) => {
- return a.order - b.order
+ .reduce((map, view) => {
+ map[view.parent!] = [...(map[view.parent!] || []), view]
+ map[view.parent!].sort((a, b) => {
+ if (typeof a.order === 'number' || typeof b.order === 'number') {
+ return (a.order ?? 0) - (b.order ?? 0)
+ }
+ return collator.compare(a.name, b.name)
})
- return list
- }, {})
+ return map
+ }, {} as Record<string, View[]>)
},
},
watch: {
- currentView(view, oldView) {
- logger.debug('View changed', { id: view.id, view })
- this.showView(view, oldView)
+ currentViewId(newView, oldView) {
+ if (this.currentViewId !== this.currentView?.id) {
+ // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
+ const view = this.views.find(({ id }) => id === this.currentViewId)!
+ // The new view as active
+ this.showView(view)
+ logger.debug(`Navigation changed from ${oldView} to ${newView}`, { to: view })
+ }
},
},
- beforeMount() {
- if (this.currentView) {
- logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
- this.showView(this.currentView)
- }
+ created() {
+ subscribe('files:folder-tree:initialized', this.loadExpandedViews)
+ subscribe('files:folder-tree:expanded', this.loadExpandedViews)
+ },
- subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
+ beforeMount() {
+ // This is guaranteed to be a view because `currentViewId` falls back to the default 'files' view
+ const view = this.views.find(({ id }) => id === this.currentViewId)!
+ this.showView(view)
+ logger.debug('Navigation mounted. Showing requested view', { view })
},
methods: {
- /**
- * @param {Navigation} view the new active view
- * @param {Navigation} oldView the old active view
- */
- showView(view, oldView) {
- // Closing any opened sidebar
- window?.OCA?.Files?.Sidebar?.close?.()
-
- if (view.legacy) {
- const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
- document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
- el.classList.add('hidden')
- })
- newAppContent.classList.remove('hidden')
-
- // Triggering legacy navigation events
- const { dir = '/' } = OC.Util.History.parseUrlQuery()
- const params = { itemId: view.id, dir }
-
- logger.debug('Triggering legacy navigation event', params)
- window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
- window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
-
- }
-
- this.Navigation.setActive(view)
- emit('files:navigation:changed', view)
- },
-
- /**
- * Coming from the legacy files app.
- * TODO: remove when all views are migrated.
- *
- * @param {Navigation} view the new active view
- */
- onLegacyNavigationChanged({ id } = { id: 'files' }) {
- const view = this.Navigation.views.find(view => view.id === id)
- if (view && view.legacy && view.id !== this.currentView.id) {
- // Force update the current route as the request comes
- // from the legacy files app router
- this.$router.replace({ ...this.$route, params: { view: view.id } })
- this.Navigation.setActive(view)
- this.showView(view)
+ async loadExpandedViews() {
+ 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)
}
},
/**
- * Expand/collapse a a view with children and permanently
- * save this setting in the server.
- *
- * @param {Navigation} view the view to toggle
- */
- onToggleExpand(view) {
- // Invert state
- view.expanded = !view.expanded
- axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded })
- },
-
- /**
- * Generate the route to a view
- *
- * @param {Navigation} view the view to toggle
+ * Set the view as active on the navigation and handle internal state
+ * @param view View to set active
*/
- generateToNavigation(view) {
- if (view.params) {
- const { dir, fileid } = view.params
- return { name: 'filelist', params: view.params, query: { dir, fileid } }
- }
- return { name: 'filelist', params: { view: view.id } }
+ showView(view: View) {
+ // Closing any opened sidebar
+ window.OCA?.Files?.Sidebar?.close?.()
+ getNavigation().setActive(view)
+ emit('files:navigation:changed', view)
},
/**
@@ -249,22 +192,20 @@ export default {
onSettingsClose() {
this.settingsOpened = false
},
-
- t: translate,
},
-}
+})
</script>
<style scoped lang="scss">
-// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in
-.app-navigation::v-deep .app-navigation-entry-icon {
- background-repeat: no-repeat;
- background-position: center;
-}
-
-.app-navigation > ul.app-navigation__list {
- // Use flex gap value for more elegant spacing
- padding-bottom: var(--default-grid-baseline, 4px);
+.app-navigation {
+ :deep(.app-navigation-entry.active .button-vue.icon-collapse:not(:hover)) {
+ color: var(--color-primary-element-text);
+ }
+
+ > ul.app-navigation__list {
+ // Use flex gap value for more elegant spacing
+ padding-bottom: var(--default-grid-baseline, 4px);
+ }
}
.app-navigation-entry__settings {
@@ -274,4 +215,14 @@ export default {
// Prevent shrinking or growing
flex: 0 0 auto;
}
+
+.files-navigation {
+ &__list {
+ height: 100%; // Fill all available space for sticky views
+ }
+
+ :deep(.app-navigation__content > ul.app-navigation__list) {
+ will-change: scroll-position;
+ }
+}
</style>
diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue
new file mode 100644
index 00000000000..9db346ea35d
--- /dev/null
+++ b/apps/files/src/views/ReferenceFileWidget.vue
@@ -0,0 +1,306 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div v-if="!accessible" class="widget-file widget-file--no-access">
+ <span class="widget-file__image widget-file__image--icon">
+ <FolderIcon v-if="isFolder" :size="88" />
+ <FileIcon v-else :size="88" />
+ </span>
+ <span class="widget-file__details">
+ <p class="widget-file__title">
+ {{ t('files', 'File cannot be accessed') }}
+ </p>
+ <p class="widget-file__description">
+ {{ t('files', 'The file could not be found or you do not have permissions to view it. Ask the sender to share it.') }}
+ </p>
+ </span>
+ </div>
+
+ <!-- Live preview if a handler is available -->
+ <component :is="viewerHandler.component"
+ v-else-if="interactive && viewerHandler && !failedViewer"
+ :active="false /* prevent video from autoplaying */"
+ :can-swipe="false"
+ :can-zoom="false"
+ :is-embedded="true"
+ v-bind="viewerFile"
+ :file-list="[viewerFile]"
+ :is-full-screen="false"
+ :is-sidebar-shown="false"
+ class="widget-file widget-file--interactive"
+ @error="failedViewer = true" />
+
+ <!-- The file is accessible -->
+ <a v-else
+ class="widget-file widget-file--link"
+ :href="richObject.link"
+ target="_blank"
+ @click="navigate">
+ <span class="widget-file__image" :class="filePreviewClass" :style="filePreviewStyle">
+ <template v-if="!previewUrl">
+ <FolderIcon v-if="isFolder" :size="88" fill-color="var(--color-primary-element)" />
+ <FileIcon v-else :size="88" />
+ </template>
+ </span>
+ <span class="widget-file__details">
+ <p class="widget-file__title">{{ richObject.name }}</p>
+ <p class="widget-file__description">{{ fileSize }}<br>{{ fileMtime }}</p>
+ <p class="widget-file__link">{{ filePath }}</p>
+ </span>
+ </a>
+</template>
+
+<script lang="ts">
+import { defineComponent, type Component, type PropType } from 'vue'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { Node } from '@nextcloud/files'
+import FileIcon from 'vue-material-design-icons/File.vue'
+import FolderIcon from 'vue-material-design-icons/Folder.vue'
+import path from 'path'
+
+// see lib/private/Collaboration/Reference/File/FileReferenceProvider.php
+type Ressource = {
+ id: number
+ name: string
+ size: number
+ path: string
+ link: string
+ mimetype: string
+ mtime: number // as unix timestamp
+ 'preview-available': boolean
+}
+
+type ViewerHandler = {
+ id: string
+ group: string
+ mimes: string[]
+ component: Component
+}
+
+/**
+ * Minimal mock of the legacy Viewer FileInfo
+ * TODO: replace by Node object
+ */
+type ViewerFile = {
+ filename: string // the path to the root folder
+ basename: string // the file name
+ lastmod: Date // the last modification date
+ size: number // the file size in bytes
+ type: string
+ mime: string
+ fileid: number
+ failed: boolean
+ loaded: boolean
+ davPath: string
+ source: string
+}
+
+export default defineComponent({
+ name: 'ReferenceFileWidget',
+ components: {
+ FolderIcon,
+ FileIcon,
+ },
+ props: {
+ richObject: {
+ type: Object as PropType<Ressource>,
+ required: true,
+ },
+ accessible: {
+ type: Boolean,
+ default: true,
+ },
+ interactive: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ data() {
+ return {
+ previewUrl: null as string | null,
+ failedViewer: false,
+ }
+ },
+
+ computed: {
+ availableViewerHandlers(): ViewerHandler[] {
+ return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[]
+ },
+ viewerHandler(): ViewerHandler | undefined {
+ return this.availableViewerHandlers
+ .find(handler => handler.mimes.includes(this.richObject.mimetype))
+ },
+ viewerFile(): ViewerFile {
+ const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`)
+ .replace(/\/\/$/, '/')
+ return {
+ filename: this.richObject.path,
+ basename: this.richObject.name,
+ lastmod: new Date(this.richObject.mtime * 1000),
+ size: this.richObject.size,
+ type: 'file',
+ mime: this.richObject.mimetype,
+ fileid: this.richObject.id,
+ failed: false,
+ loaded: true,
+ davPath: davSource,
+ source: davSource,
+ }
+ },
+
+ fileSize() {
+ return window.OC.Util.humanFileSize(this.richObject.size)
+ },
+ fileMtime() {
+ return window.OC.Util.relativeModifiedDate(this.richObject.mtime * 1000)
+ },
+ filePath() {
+ return path.dirname(this.richObject.path)
+ },
+ filePreviewStyle() {
+ if (this.previewUrl) {
+ return {
+ backgroundImage: 'url(' + this.previewUrl + ')',
+ }
+ }
+ return {}
+ },
+ filePreviewClass() {
+ if (this.previewUrl) {
+ return 'widget-file__image--preview'
+ }
+ return 'widget-file__image--icon'
+
+ },
+ isFolder() {
+ return this.richObject.mimetype === 'httpd/unix-directory'
+ },
+ },
+
+ mounted() {
+ if (this.richObject['preview-available']) {
+ const previewUrl = generateUrl('/core/preview?fileId={fileId}&x=250&y=250', {
+ fileId: this.richObject.id,
+ })
+ const img = new Image()
+ img.onload = () => {
+ this.previewUrl = previewUrl
+ }
+ img.onerror = err => {
+ console.error('could not load recommendation preview', err)
+ }
+ img.src = previewUrl
+ }
+ },
+ methods: {
+ navigate(event) {
+ if (this.isFolder) {
+ event.stopPropagation()
+ event.preventDefault()
+ this.openFilePicker()
+ } else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) {
+ event.stopPropagation()
+ event.preventDefault()
+ window?.OCA?.Viewer?.open({ path: this.richObject.path })
+ }
+ },
+
+ openFilePicker() {
+ const picker = getFilePickerBuilder(t('settings', 'Your files'))
+ .allowDirectories(true)
+ .setMultiSelect(false)
+ .addButton({
+ id: 'open',
+ label: this.t('settings', 'Open in files'),
+ callback(nodes: Node[]) {
+ if (nodes[0]) {
+ window.open(generateUrl('/f/{fileid}', {
+ fileid: nodes[0].fileid,
+ }))
+ }
+ },
+ type: 'primary',
+ })
+ .disableNavigation()
+ .startAt(this.richObject.path)
+ .build()
+ picker.pick()
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.widget-file {
+ display: flex;
+ flex-grow: 1;
+ color: var(--color-main-text) !important;
+ text-decoration: none !important;
+ padding: 0 !important;
+
+ &__image {
+ width: 30%;
+ min-width: 160px;
+ max-width: 320px;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+
+ &--icon {
+ min-width: 88px;
+ max-width: 88px;
+ padding: 12px;
+ padding-inline-end: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ &__title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: bold;
+ }
+
+ &__details {
+ padding: 12px;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ p {
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ &__description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ }
+
+ // No preview, standard link to ressource
+ &--link {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &--interactive {
+ position: relative;
+ height: 400px;
+ max-height: 50vh;
+ margin: 0;
+ }
+}
+</style>
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 9a63fea4924..bfac8e0b3d6 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -1,36 +1,70 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- -
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcAppSettingsDialog :open="open"
:show-navigation="true"
- :title="t('files', 'Files settings')"
+ :name="t('files', 'Files settings')"
@update:open="onClose">
<!-- Settings API-->
- <NcAppSettingsSection id="settings" :title="t('files', 'Files settings')">
- <NcCheckboxRadioSwitch :checked.sync="show_hidden"
+ <NcAppSettingsSection id="settings" :name="t('files', 'General')">
+ <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)">
+ {{ t('files', 'Sort favorites first') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_folders_first"
+ :checked="userConfig.sort_folders_first"
+ @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', 'Folder tree') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <!-- Appearance -->
+ <NcAppSettingsSection id="settings" :name="t('files', 'Appearance')">
+ <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 :checked.sync="crop_image_previews"
+ <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="show_files_extensions"
+ :checked="userConfig.show_files_extensions"
+ @update:checked="setConfig('show_files_extensions', $event)">
+ {{ t('files', 'Show file extensions') }}
+ </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>
@@ -39,19 +73,21 @@
<!-- Settings API-->
<NcAppSettingsSection v-if="settings.length !== 0"
id="more-settings"
- :title="t('files', 'Additional settings')">
+ :name="t('files', 'Additional settings')">
<template v-for="setting in settings">
<Setting :key="setting.name" :el="setting.el" />
</template>
</NcAppSettingsSection>
<!-- Webdav URL-->
- <NcAppSettingsSection id="webdav" :title="t('files', 'Webdav')">
+ <NcAppSettingsSection id="webdav" :name="t('files', 'WebDAV')">
<NcInputField id="webdav-url-input"
+ :label="t('files', 'WebDAV URL')"
:show-trailing-button="true"
:success="webdavUrlCopied"
- :trailing-button-label="t('files', 'Copy to clipboard')"
+ :trailing-button-label="t('files', 'Copy')"
:value="webdavUrl"
+ class="webdav-url-input"
readonly="readonly"
type="url"
@focus="$event.target.select()"
@@ -61,34 +97,209 @@
</template>
</NcInputField>
<em>
- <a :href="webdavDocs" target="_blank" rel="noreferrer noopener">
- {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗
+ <a class="setting-link"
+ :href="webdavDocs"
+ target="_blank"
+ rel="noreferrer noopener">
+ {{ t('files', 'How to access files using WebDAV') }} ↗
</a>
</em>
+ <br>
+ <em v-if="isTwoFactorEnabled">
+ <a class="setting-link" :href="appPasswordUrl">
+ {{ 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')">
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="userConfig.show_dialog_file_extension"
+ @update:checked="setConfig('show_dialog_file_extension', $event)">
+ {{ t('files', 'Warn before changing a file extension') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="userConfig.show_dialog_deletion"
+ @update:checked="setConfig('show_dialog_deletion', $event)">
+ {{ t('files', 'Warn before deleting files') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <NcAppSettingsSection id="shortcuts"
+ :name="t('files', 'Keyboard shortcuts')">
+
+ <h3>{{ t('files', 'Actions') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>a</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'File actions') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>F2</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Rename') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Del</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Delete') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>s</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Add or remove favorite') }}
+ </dd>
+ </div>
+ <div v-if="isSystemtagsEnabled">
+ <dt class="shortcut-key">
+ <kbd>t</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Manage tags') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'Selection') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>A</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select all files') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>ESC</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Deselect all') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>Space</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select or deselect') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Ctrl</kbd> + <kbd>Shift</kbd> <span>+ <kbd>Space</kbd></span>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Select a range') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'Navigation') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>Alt</kbd> + <kbd>↑</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to parent folder') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>↑</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to file above') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>↓</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go to file below') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>←</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go left in grid') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>→</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Go right in grid') }}
+ </dd>
+ </div>
+ </dl>
+
+ <h3>{{ t('files', 'View') }}</h3>
+ <dl>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>V</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Toggle grid view') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>D</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Open file sidebar') }}
+ </dd>
+ </div>
+ <div>
+ <dt class="shortcut-key">
+ <kbd>?</kbd>
+ </dt>
+ <dd class="shortcut-description">
+ {{ t('files', 'Show those shortcuts') }}
+ </dd>
+ </div>
+ </dl>
</NcAppSettingsSection>
</NcAppSettingsDialog>
</template>
<script>
-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 Clipboard from 'vue-material-design-icons/Clipboard.vue'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField'
-import Setting from '../components/Setting.vue'
-
-import { emit } from '@nextcloud/event-bus'
-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 { translate } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
-const userConfig = loadState('files', 'config', {
- show_hidden: false,
- crop_image_previews: true,
-})
+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'
export default {
name: 'Settings',
@@ -108,21 +319,55 @@ export default {
},
},
- data() {
+ setup() {
+ const userConfigStore = useUserConfigStore()
+ const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true
return {
+ isSystemtagsEnabled,
+ userConfigStore,
+ t,
+ }
+ },
- ...userConfig,
-
+ data() {
+ return {
// Settings API
settings: window.OCA?.Files?.Settings?.settings || [],
// Webdav infos
webdavUrl: generateRemoteUrl('dav/files/' + encodeURIComponent(getCurrentUser()?.uid)),
webdavDocs: 'https://docs.nextcloud.com/server/stable/go.php?to=user-webdav',
+ 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)),
}
},
+ computed: {
+ 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() {
+ // ? opens the settings dialog on the keyboard shortcuts section
+ useHotKey('?', this.showKeyboardShortcuts, {
+ stop: true,
+ prevent: true,
+ })
+ },
+
beforeMount() {
// Update the settings API entries state
this.settings.forEach(setting => setting.open())
@@ -139,10 +384,7 @@ export default {
},
setConfig(key, value) {
- emit('files:config:updated', { key, value })
- axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
- value,
- })
+ this.userConfigStore.update(key, value)
},
async copyCloudId() {
@@ -156,17 +398,47 @@ export default {
await navigator.clipboard.writeText(this.webdavUrl)
this.webdavUrlCopied = true
- showSuccess(t('files', 'Webdav URL copied to clipboard'))
+ showSuccess(t('files', 'WebDAV URL copied'))
setTimeout(() => {
this.webdavUrlCopied = false
}, 5000)
},
- t: translate,
+ async showKeyboardShortcuts() {
+ this.$emit('update:open', true)
+
+ await this.$nextTick()
+ document.getElementById('settings-section_shortcuts').scrollIntoView({
+ behavior: 'smooth',
+ inline: 'nearest',
+ })
+ },
},
}
</script>
<style lang="scss" scoped>
+.files-settings {
+ &__default-view {
+ margin-bottom: 0.5rem;
+ }
+}
+
+.setting-link:hover {
+ text-decoration: underline;
+}
+.shortcut-key {
+ width: 160px;
+ // some shortcuts are too long to fit in one line
+ white-space: normal;
+ span {
+ // force portion of a shortcut on a new line for nicer display
+ 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 c97fb304c32..40a16d42b42 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -1,49 +1,60 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcAppSidebar v-if="file"
ref="sidebar"
+ data-cy-sidebar
v-bind="appSidebar"
:force-menu="true"
- tabindex="0"
@close="close"
@update:active="setActiveTab"
- @update:starred="toggleStarred"
@[defaultActionListener].stop.prevent="onDefaultAction"
@opening="handleOpening"
@opened="handleOpened"
@closing="handleClosing"
@closed="handleClosed">
+ <template v-if="fileInfo" #subname>
+ <div class="sidebar__subname">
+ <NcIconSvgWrapper v-if="fileInfo.isFavourited"
+ :path="mdiStar"
+ :name="t('files', 'Favorite')"
+ inline />
+ <span>{{ size }}</span>
+ <span class="sidebar__subname-separator">•</span>
+ <NcDateTime :timestamp="fileInfo.mtime" />
+ <span class="sidebar__subname-separator">•</span>
+ <span>{{ t('files', 'Owner') }}</span>
+ <NcUserBubble :user="ownerId"
+ :display-name="nodeOwnerLabel" />
+ </div>
+ </template>
+
<!-- TODO: create a standard to allow multiple elements here? -->
<template v-if="fileInfo" #description>
- <LegacyView v-for="view in views"
- :key="view.cid"
- :component="view"
- :file-info="fileInfo" />
+ <div class="sidebar__description">
+ <SystemTags v-if="isSystemTagsEnabled && showTagsDefault"
+ v-show="showTags"
+ :disabled="!fileInfo?.canEdit()"
+ :file-id="fileInfo.id" />
+ <LegacyView v-for="view in views"
+ :key="view.cid"
+ :component="view"
+ :file-info="fileInfo" />
+ </div>
</template>
<!-- Actions menu -->
<template v-if="fileInfo" #secondary-actions>
+ <NcActionButton :close-after-click="true"
+ @click="toggleStarred(!fileInfo.isFavourited)">
+ <template #icon>
+ <NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStarOutline : mdiStar" />
+ </template>
+ {{ fileInfo.isFavourited ? t('files', 'Remove from favorites') : t('files', 'Add to favorites') }}
+ </NcActionButton>
<!-- TODO: create proper api for apps to register actions
And inject themselves here. -->
<NcActionButton v-if="isSystemTagsEnabled"
@@ -81,41 +92,71 @@
</template>
</NcAppSidebar>
</template>
-<script>
+<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 { encodePath } from '@nextcloud/paths'
+import { fetchNode } from '../services/WebdavClient.ts'
+import { generateUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { getCurrentUser } from '@nextcloud/auth'
+import { mdiStar, mdiStarOutline } from '@mdi/js'
+import { ShareType } from '@nextcloud/sharing'
+import { showError } from '@nextcloud/dialogs'
import $ from 'jquery'
import axios from '@nextcloud/axios'
-import { emit } from '@nextcloud/event-bus'
-import moment from '@nextcloud/moment'
-import { Type as ShareTypes } from '@nextcloud/sharing'
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
+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'
-import SidebarTab from '../components/SidebarTab'
-import LegacyView from '../components/LegacyView'
+import FileInfo from '../services/FileInfo.js'
+import LegacyView from '../components/LegacyView.vue'
+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: {
+ LegacyView,
NcActionButton,
NcAppSidebar,
+ NcDateTime,
NcEmptyContent,
- LegacyView,
+ NcIconSvgWrapper,
SidebarTab,
+ SystemTags,
+ NcUserBubble,
+ },
+
+ setup() {
+ const currentUser = getCurrentUser()
+
+ // Non reactive properties
+ return {
+ currentUser,
+
+ mdiStar,
+ mdiStarOutline,
+ }
},
data() {
return {
// reactive state
Sidebar: OCA.Files.Sidebar.state,
+ showTags: false,
+ showTagsDefault: true,
error: null,
loading: true,
fileInfo: null,
- starLoading: false,
+ node: null,
isFullScreen: false,
hasLowHeight: false,
}
@@ -157,14 +198,12 @@ export default {
* @return {string}
*/
davPath() {
- const user = OC.getCurrentUser().uid
- return OC.linkToRemote(`dav/files/${user}${encodePath(this.file)}`)
+ return `${davRemoteURL}${davRootPath}${encodePath(this.file)}`
},
/**
* Current active tab handler
*
- * @param {string} id the tab id to set as active
* @return {string} the current active tab
*/
activeTab() {
@@ -172,39 +211,12 @@ export default {
},
/**
- * Sidebar subtitle
- *
- * @return {string}
- */
- subtitle() {
- return `${this.size}, ${this.time}`
- },
-
- /**
- * File last modified formatted string
- *
- * @return {string}
- */
- time() {
- return OC.Util.relativeModifiedDate(this.fileInfo.mtime)
- },
-
- /**
- * File last modified full string
- *
- * @return {string}
- */
- fullTime() {
- return moment(this.fileInfo.mtime).format('LLL')
- },
-
- /**
* File size formatted string
*
* @return {string}
*/
size() {
- return OC.Util.humanFileSize(this.fileInfo.size)
+ return formatFileSize(this.fileInfo?.size)
},
/**
@@ -225,7 +237,6 @@ export default {
if (this.fileInfo) {
return {
'data-mimetype': this.fileInfo.mimetype,
- 'star-loading': this.starLoading,
active: this.activeTab,
background: this.background,
class: {
@@ -234,24 +245,27 @@ export default {
},
compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen,
loading: this.loading,
- starred: this.fileInfo.isFavourited,
- subtitle: this.subtitle,
- subtitleTooltip: this.fullTime,
- title: this.fileInfo.name,
- titleTooltip: this.fileInfo.name,
+ name: this.node?.displayname ?? this.fileInfo.name,
+ title: this.node?.displayname ?? this.fileInfo.name,
}
} else if (this.error) {
return {
key: 'error', // force key to re-render
- subtitle: '',
- title: '',
+ subname: '',
+ name: '',
+ class: {
+ 'app-sidebar--full': this.isFullScreen,
+ },
}
}
// no fileInfo yet, showing empty data
return {
loading: this.loading,
- subtitle: '',
- title: '',
+ subname: '',
+ name: '',
+ class: {
+ 'app-sidebar--full': this.isFullScreen,
+ },
}
},
@@ -282,14 +296,36 @@ export default {
},
isSystemTagsEnabled() {
- return OCA && 'SystemTags' in OCA
+ return getCapabilities()?.systemtags?.enabled === true
+ },
+ ownerId() {
+ return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid
+ },
+ currentUserIsOwner() {
+ return this.ownerId === this.currentUser.uid
+ },
+ nodeOwnerLabel() {
+ let ownerDisplayName = this.node?.attributes?.['owner-display-name']
+ if (this.currentUserIsOwner) {
+ ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})`
+ }
+ return ownerDisplayName
+ },
+ sharedMultipleTimes() {
+ if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
+ return t('files', 'Shared multiple times with different people')
+ }
+ return null
},
},
created() {
+ subscribe('files:node:deleted', this.onNodeDeleted)
+
window.addEventListener('resize', this.handleWindowResize)
this.handleWindowResize()
},
beforeDestroy() {
+ unsubscribe('file:node:deleted', this.onNodeDeleted)
window.removeEventListener('resize', this.handleWindowResize)
},
@@ -314,8 +350,9 @@ export default {
},
getPreviewIfAny(fileInfo) {
- if (fileInfo.hasPreview && !this.isFullScreen) {
- return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`)
+ if (fileInfo?.hasPreview && !this.isFullScreen) {
+ const etag = fileInfo?.etag || ''
+ return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`)
}
return this.getIconUrl(fileInfo)
},
@@ -328,7 +365,7 @@ export default {
* @return {string} Url to the icon for mimeType
*/
getIconUrl(fileInfo) {
- const mimeType = fileInfo.mimetype || 'application/octet-stream'
+ const mimeType = fileInfo?.mimetype || 'application/octet-stream'
if (mimeType === 'httpd/unix-directory') {
// use default folder icon
if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') {
@@ -338,8 +375,8 @@ export default {
} else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') {
return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType)
} else if (fileInfo.shareTypes && (
- fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) > -1
- || fileInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_EMAIL) > -1)
+ fileInfo.shareTypes.indexOf(ShareType.Link) > -1
+ || fileInfo.shareTypes.indexOf(ShareType.Email) > -1)
) {
return OC.MimeType.getIconUrl('dir-public')
} else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) {
@@ -357,17 +394,23 @@ export default {
*/
setActiveTab(id) {
OCA.Files.Sidebar.setActiveTab(id)
+ this.tabs.forEach(tab => {
+ try {
+ tab.setIsActive(id === tab.id)
+ } catch (error) {
+ logger.error('Error while setting tab active state', { error, id: tab.id, tab })
+ }
+ })
},
/**
- * Toggle favourite state
+ * Toggle favorite state
* TODO: better implementation
*
- * @param {boolean} state favourited or not
+ * @param {boolean} state is favorite or not
*/
async toggleStarred(state) {
try {
- this.starLoading = true
await axios({
method: 'PROPPATCH',
url: this.davPath,
@@ -381,17 +424,28 @@ export default {
</d:propertyupdate>`,
})
- // TODO: Obliterate as soon as possible and use events with new files app
- // Terrible fallback for legacy files: toggle filelist as well
- if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) {
- OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList)
- }
+ /**
+ * TODO: adjust this when the Sidebar is finally using File/Folder classes
+ * @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115
+ */
+ const isDir = this.fileInfo.type === 'dir'
+ const Node = isDir ? Folder : File
+ const node = new Node({
+ fileid: this.fileInfo.id,
+ source: `${davRemoteURL}${davRootPath}${this.file}`,
+ root: davRootPath,
+ mime: isDir ? undefined : this.fileInfo.mimetype,
+ attributes: {
+ favorite: 1,
+ },
+ })
+ emit(state ? 'files:favorites:added' : 'files:favorites:removed', node)
+ this.fileInfo.isFavourited = state
} catch (error) {
- OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
- console.error('Unable to change favourite state', error)
+ showError(t('files', 'Unable to change the favorite state of the file'))
+ logger.error('Unable to change favorite state', { error })
}
- this.starLoading = false
},
onDefaultAction() {
@@ -410,9 +464,10 @@ export default {
* Toggle the tags selector
*/
toggleTags() {
- if (OCA.SystemTags && OCA.SystemTags.View) {
- OCA.SystemTags.View.toggle()
- }
+ // toggle
+ this.showTags = !this.showTags
+ // save the new state
+ this.setShowTagsDefault(this.showTags)
},
/**
@@ -423,38 +478,50 @@ export default {
* @throws {Error} loading failure
*/
async open(path) {
+ if (!path || path.trim() === '') {
+ throw new Error(`Invalid path '${path}'`)
+ }
+
+ // Only focus the tab when the selected file/tab is changed in already opened sidebar
+ // Focusing the sidebar on first file open is handled by NcAppSidebar
+ const focusTabAfterLoad = !!this.Sidebar.file
+
// update current opened file
this.Sidebar.file = path
- if (path && path.trim() !== '') {
- // reset data, keep old fileInfo to not reload all tabs and just hide them
- this.error = null
- this.loading = true
+ // reset data, keep old fileInfo to not reload all tabs and just hide them
+ this.error = null
+ this.loading = true
- try {
- this.fileInfo = await FileInfo(this.davPath)
- // adding this as fallback because other apps expect it
- this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
-
- // DEPRECATED legacy views
- // TODO: remove
- this.views.forEach(view => {
- view.setFileInfo(this.fileInfo)
- })
-
- this.$nextTick(() => {
- if (this.$refs.tabs) {
- this.$refs.tabs.updateTabs()
- }
- })
- } catch (error) {
- this.error = t('files', 'Error while loading the file data')
- console.error('Error while loading the file data', error)
+ try {
+ 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('/')
+
+ // DEPRECATED legacy views
+ // TODO: remove
+ this.views.forEach(view => {
+ view.setFileInfo(this.fileInfo)
+ })
+
+ await this.$nextTick()
- throw new Error(error)
- } finally {
- this.loading = false
+ this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
+
+ this.loading = false
+
+ await this.$nextTick()
+
+ if (focusTabAfterLoad && this.$refs.sidebar) {
+ this.$refs.sidebar.focusActiveTabContent()
}
+ } catch (error) {
+ this.loading = false
+ this.error = t('files', 'Error while loading the file data')
+ console.error('Error while loading the file data', error)
+
+ throw new Error(error)
}
},
@@ -463,10 +530,21 @@ export default {
*/
close() {
this.Sidebar.file = ''
+ this.showTags = false
this.resetData()
},
/**
+ * Handle if the current node was deleted
+ * @param {import('@nextcloud/files').Node} node The deleted node
+ */
+ onNodeDeleted(node) {
+ if (this.fileInfo && node && this.fileInfo.id === node.fileid) {
+ this.close()
+ }
+ },
+
+ /**
* Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar
*
* @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen.
@@ -483,6 +561,15 @@ export default {
},
/**
+ * Allow to set whether tags should be shown by default from OCA.Files.Sidebar
+ *
+ * @param {boolean} showTagsDefault - Whether or not to show the tags by default.
+ */
+ setShowTagsDefault(showTagsDefault) {
+ this.showTagsDefault = showTagsDefault
+ },
+
+ /**
* Emit SideBar events.
*/
handleOpening() {
@@ -501,11 +588,11 @@ export default {
this.hasLowHeight = document.documentElement.clientHeight < 1024
},
},
-}
+})
</script>
<style lang="scss" scoped>
.app-sidebar {
- &--has-preview::v-deep {
+ &--has-preview:deep {
.app-sidebar-header__figure {
background-size: cover;
}
@@ -525,12 +612,40 @@ export default {
height: 100% !important;
}
+ :deep {
+ .app-sidebar-header__description {
+ margin: 0 16px 4px 16px !important;
+ }
+ }
+
.svg-icon {
- ::v-deep svg {
+ :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
}
}
+
+.sidebar__subname {
+ display: flex;
+ align-items: center;
+ gap: 0 8px;
+
+ &-separator {
+ display: inline-block;
+ font-weight: bold !important;
+ }
+
+ .user-bubble__wrapper {
+ display: inline-flex;
+ }
+}
+
+.sidebar__description {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 8px 0;
+ }
</style>
diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue
index 33b925aa2ed..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -1,30 +1,13 @@
<!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcModal v-if="opened"
:clear-view-delay="-1"
class="templates-picker"
- size="normal"
+ size="large"
@close="close">
<form class="templates-picker__form"
:style="style"
@@ -34,7 +17,9 @@
<!-- Templates list -->
<ul class="templates-picker__list">
<TemplatePreview v-bind="emptyTemplate"
+ ref="emptyTemplatePreview"
:checked="checked === emptyTemplate.fileid"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
<TemplatePreview v-for="template in provider.templates"
@@ -42,14 +27,12 @@
v-bind="template"
:checked="checked === template.fileid"
:ratio="provider.ratio"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
</ul>
<!-- Cancel and submit -->
<div class="templates-picker__buttons">
- <button @click="close">
- {{ t('files', 'Cancel') }}
- </button>
<input type="submit"
class="primary"
:value="t('files', 'Create')"
@@ -63,21 +46,29 @@
</NcModal>
</template>
-<script>
-import { normalize } from 'path'
-import { showError } from '@nextcloud/dialogs'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal'
-
-import { getCurrentDirectory } from '../utils/davUtils'
-import { createFromTemplate, getTemplates } from '../services/Templates'
-import TemplatePreview from '../components/TemplatePreview'
+<script lang="ts">
+import type { TemplateFile } from '../types.ts'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError, spawnDialog } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import { File } from '@nextcloud/files'
+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, getTemplateFields } from '../services/Templates.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'
const border = 2
const margin = 8
-const width = margin * 20
-export default {
+export default defineComponent({
name: 'TemplatePicker',
components: {
@@ -87,9 +78,12 @@ export default {
},
props: {
- logger: {
+ /**
+ * The parent folder where to create the node
+ */
+ parent: {
type: Object,
- required: true,
+ default: () => null,
},
},
@@ -98,44 +92,57 @@ export default {
// Check empty template by default
checked: -1,
loading: false,
- name: null,
+ name: null as string|null,
opened: false,
- provider: null,
+ provider: null as TemplateFile|null,
}
},
computed: {
- /**
- * Strip away extension from name
- *
- * @return {string}
- */
+ extension() {
+ return extname(this.name ?? '')
+ },
+
nameWithoutExt() {
- return this.name.indexOf('.') > -1
- ? this.name.split('.').slice(0, -1).join('.')
- : this.name
+ // Strip extension from name if defined
+ return !this.extension
+ ? this.name!
+ : this.name!.slice(0, 0 - this.extension.length)
},
emptyTemplate() {
return {
basename: t('files', 'Blank'),
fileid: -1,
- filename: this.t('files', 'Blank'),
+ filename: t('files', 'Blank'),
hasPreview: false,
mime: this.provider?.mimetypes[0] || this.provider?.mimetypes,
}
},
selectedTemplate() {
- return this.provider.templates.find(template => template.fileid === this.checked)
+ if (!this.provider) {
+ return null
+ }
+
+ return this.provider.templates!.find((template) => template.fileid === this.checked)
},
/**
- * Style css vars bin,d
+ * Style css vars bind
*
* @return {object}
*/
style() {
+ if (!this.provider) {
+ return {}
+ }
+
+ // Fallback to 16:9 landscape ratio
+ const ratio = this.provider.ratio ? this.provider.ratio : 1.77
+ // Landscape templates should be wider than tall ones
+ // We fit 3 templates per row at max for landscape and 4 for portrait
+ const width = ratio > 1 ? margin * 30 : margin * 20
return {
'--margin': margin + 'px',
'--width': width + 'px',
@@ -147,14 +154,15 @@ export default {
},
methods: {
+ t,
+
/**
* Open the picker
*
* @param {string} name the file name to create
* @param {object} provider the template provider picked
*/
- async open(name, provider) {
-
+ async open(name: string, provider) {
this.checked = this.emptyTemplate.fileid
this.name = name
this.provider = provider
@@ -174,6 +182,11 @@ export default {
// Else, open the picker
this.opened = true
+
+ // Set initial focus to the empty template preview
+ this.$nextTick(() => {
+ this.$refs.emptyTemplatePreview?.focus()
+ })
},
/**
@@ -190,60 +203,98 @@ export default {
/**
* Manages the radio template picker change
*
- * @param {number} fileid the selected template file id
+ * @param fileid the selected template file id
*/
- onCheck(fileid) {
+ onCheck(fileid: number) {
this.checked = fileid
},
- async onSubmit() {
- this.loading = true
- const currentDirectory = getCurrentDirectory()
- const fileList = OCA?.Files?.App?.currentFileList
+ onConfirmClick(fileid: number) {
+ if (fileid === this.checked) {
+ this.onSubmit()
+ }
+ },
+
+ 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
if (this.nameWithoutExt === this.name) {
- this.logger.debug('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
- this.name = this.name + this.provider?.extension
+ logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
+ this.name = `${this.name}${this.provider?.extension ?? ''}`
}
try {
const fileInfo = await createFromTemplate(
normalize(`${currentDirectory}/${this.name}`),
- this.selectedTemplate?.filename,
- this.selectedTemplate?.templateType,
+ this.selectedTemplate?.filename as string ?? '',
+ this.selectedTemplate?.templateType as string ?? '',
+ templateFields,
)
- this.logger.debug('Created new file', fileInfo)
-
- // Fetch FileInfo and model
- const data = await fileList?.addAndFetchFileInfo(this.name).then((status, data) => data)
- const model = new OCA.Files.FileInfoModel(data, {
- filesClient: fileList?.filesClient,
+ logger.debug('Created new file', fileInfo)
+
+ const owner = getCurrentUser()?.uid || null
+ const node = new File({
+ id: fileInfo.fileid,
+ source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)),
+ root: `/files/${owner}`,
+ mime: fileInfo.mime,
+ mtime: new Date(fileInfo.lastmod * 1000),
+ owner,
+ size: fileInfo.size,
+ permissions: fileInfo.permissions,
+ attributes: {
+ // Inherit some attributes from parent folder like the mount type and real owner
+ 'mount-type': this.parent?.attributes?.['mount-type'],
+ 'owner-id': this.parent?.attributes?.['owner-id'],
+ 'owner-display-name': this.parent?.attributes?.['owner-display-name'],
+ ...fileInfo,
+ 'has-preview': fileInfo.hasPreview,
+ },
})
- // Run default action
- const fileAction = OCA.Files.fileActions.getDefaultFileAction(fileInfo.mime, 'file', OC.PERMISSION_ALL)
- if (fileAction) {
- fileAction.action(fileInfo.basename, {
- $file: fileList?.findFileEl(this.name),
- dir: currentDirectory,
- fileList,
- fileActions: fileList?.fileActions,
- fileInfoModel: model,
- })
- }
+ // Update files list
+ emit('files:node:created', node)
+
+ // Open the new file
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: node.fileid },
+ { dir: node.dirname, openfile: 'true' },
+ )
+ // Close the picker
this.close()
} catch (error) {
- this.logger.error('Error while creating the new file from template')
- console.error(error)
- showError(this.t('files', 'Unable to create new file from template'))
+ logger.error('Error while creating the new file from template', { error })
+ showError(t('files', 'Unable to create new file from template'))
} finally {
this.loading = false
}
},
+
+ async onSubmit() {
+ 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,
+ onSubmit: this.createFile,
+ })
+ } else {
+ this.loading = true
+ await this.createFile()
+ }
+ },
},
-}
+})
</script>
<style lang="scss" scoped>
@@ -275,11 +326,11 @@ export default {
&__buttons {
display: flex;
- justify-content: space-between;
+ justify-content: end;
padding: calc(var(--margin) * 2) var(--margin);
position: sticky;
bottom: 0;
- background-image: linear-gradient(0, var(--gradient-main-background));
+ background-image: linear-gradient(0deg, var(--gradient-main-background));
button, input[type='submit'] {
height: 44px;
@@ -287,14 +338,14 @@ export default {
}
// Make sure we're relative for the loading emptycontent on top
- ::v-deep .modal-container {
+ :deep(.modal-container) {
position: relative;
}
&__loading {
position: absolute;
top: 0;
- left: 0;
+ inset-inline-start: 0;
justify-content: center;
width: 100%;
height: 100%;
diff --git a/apps/files/src/views/favorites.spec.ts b/apps/files/src/views/favorites.spec.ts
new file mode 100644
index 00000000000..f793eb9f54c
--- /dev/null
+++ b/apps/files/src/views/favorites.spec.ts
@@ -0,0 +1,261 @@
+/* eslint-disable import/no-named-as-default-member */
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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'
+import * as eventBus from '@nextcloud/event-bus'
+
+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')
+
+window.OC = {
+ ...window.OC,
+ TAG_FAVORITE: '_$!<Favorite>!$_',
+}
+
+declare global {
+ interface Window {
+ _nc_navigation?: Navigation
+ }
+}
+
+describe('Favorites view definition', () => {
+ let Navigation
+ beforeEach(() => {
+ vi.resetAllMocks()
+
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
+ })
+
+ test('Default empty favorite view', async () => {
+ vi.spyOn(eventBus, 'subscribe')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ expect(eventBus.subscribe).toHaveBeenCalledTimes(3)
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(1, 'files:favorites:added', expect.anything())
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(2, 'files:favorites:removed', expect.anything())
+ expect(eventBus.subscribe).toHaveBeenNthCalledWith(3, 'files:node:renamed', expect.anything())
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ expect(favoritesView?.id).toBe('favorites')
+ expect(favoritesView?.name).toBe('Favorites')
+ expect(favoritesView?.caption).toBeDefined()
+ expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoritesView?.order).toBe(15)
+ expect(favoritesView?.columns).toStrictEqual([])
+ expect(favoritesView?.getContents).toBeDefined()
+ })
+
+ test('Default with favorites', async () => {
+ const favoriteFolders = [
+ new Folder({
+ id: 1,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/foo',
+ owner: 'admin',
+ }),
+ new Folder({
+ id: 2,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/bar',
+ owner: 'admin',
+ }),
+ new Folder({
+ id: 3,
+ root: '/files/admin',
+ 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(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and 3 children
+ expect(Navigation.views.length).toBe(5)
+ expect(favoritesView).toBeDefined()
+ 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]
+ expect(favoriteView).toBeDefined()
+ expect(favoriteView?.id).toBeDefined()
+ expect(favoriteView?.name).toBe(basename(folder.path))
+ expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoriteView?.order).toBe(expectedOrder[index])
+ expect(favoriteView?.params).toStrictEqual({
+ dir: folder.path,
+ fileid: String(folder.fileid),
+ view: 'favorites',
+ })
+ expect(favoriteView?.parent).toBe('favorites')
+ expect(favoriteView?.columns).toStrictEqual([])
+ expect(favoriteView?.getContents).toBeDefined()
+ })
+ })
+})
+
+describe('Dynamic update of favorite folders', () => {
+ let Navigation
+ beforeEach(() => {
+ vi.restoreAllMocks()
+
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ })
+
+ test('Add a favorite folder creates a new entry in the navigation', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:added', folder)
+ })
+
+ test('Remove a favorite folder remove the entry from the navigation column', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([
+ new Folder({
+ id: 42,
+ root: '/files/admin',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ }),
+ ]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ let favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(2)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(1)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ root: '/files/admin',
+ attributes: {
+ favorite: 1,
+ },
+ })
+
+ const fo = vi.fn()
+ eventBus.subscribe('files:favorites:removed', fo)
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+
+ expect(eventBus.emit).toHaveBeenCalledTimes(1)
+ expect(eventBus.emit).toHaveBeenCalledWith('files:favorites:removed', folder)
+ expect(fo).toHaveBeenCalled()
+
+ favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+ })
+
+ test('Renaming a favorite folder updates the navigation', async () => {
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
+
+ await registerFavoritesView()
+ const favoritesView = Navigation.views.find(view => view.id === 'favorites')
+ const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(1)
+ expect(favoritesView).toBeDefined()
+ expect(favoriteFoldersViews.length).toBe(0)
+
+ // expect(eventBus.emit).toHaveBeenCalledTimes(2)
+
+ // Create new folder to favorite
+ const folder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
+ owner: 'admin',
+ })
+
+ // Exec the action
+ await action.exec(folder, favoritesView, '/')
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:favorites:added', folder)
+
+ // Create a folder with the same id but renamed
+ const renamedFolder = new Folder({
+ id: 1,
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar.renamed',
+ owner: 'admin',
+ })
+
+ // Exec the rename action
+ eventBus.emit('files:node:renamed', renamedFolder)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:renamed', renamedFolder)
+ })
+})
diff --git a/apps/files/src/views/favorites.ts b/apps/files/src/views/favorites.ts
new file mode 100644
index 00000000000..cac776507ef
--- /dev/null
+++ b/apps/files/src/views/favorites.ts
@@ -0,0 +1,183 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+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 FolderSvg from '@mdi/svg/svg/folder.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'
+
+const generateFavoriteFolderView = function(folder: Folder, index = 0): View {
+ return new View({
+ id: generateIdFromPath(folder.path),
+ name: folder.displayname,
+
+ icon: FolderSvg,
+ order: index,
+
+ params: {
+ dir: folder.path,
+ fileid: String(folder.fileid),
+ view: 'favorites',
+ },
+
+ parent: 'favorites',
+
+ columns: [],
+
+ getContents,
+ })
+}
+
+const generateIdFromPath = function(path: string): string {
+ return `favorite-${hashCode(path)}`
+}
+
+export const registerFavoritesView = async () => {
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: 'favorites',
+ name: t('files', 'Favorites'),
+ caption: t('files', 'List of favorite files and folders.'),
+
+ emptyTitle: t('files', 'No favorites yet'),
+ emptyCaption: t('files', 'Files and folders you mark as favorite will show up here'),
+
+ icon: StarSvg,
+ order: 15,
+
+ columns: [],
+
+ getContents,
+ }))
+
+ const favoriteFolders = (await getFavoriteNodes(client)).filter(node => node.type === FileType.Folder) as Folder[]
+ const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
+ logger.debug('Generating favorites view', { favoriteFolders })
+ favoriteFoldersViews.forEach(view => Navigation.register(view))
+
+ /**
+ * Update favorites navigation when a new folder is added
+ */
+ subscribe('files:favorites:added', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ addToFavorites(node as Folder)
+ })
+
+ /**
+ * Remove favorites navigation when a folder is removed
+ */
+ subscribe('files:favorites:removed', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ // Sanity check
+ if (node.path === null || !node.root?.startsWith('/files')) {
+ logger.error('Favorite folder is not within user files root', { node })
+ return
+ }
+
+ removePathFromFavorites(node.path)
+ })
+
+ /**
+ * Update favorites navigation when a folder is renamed
+ */
+ subscribe('files:node:renamed', (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+
+ if (node.attributes.favorite !== 1) {
+ return
+ }
+
+ updateNodeFromFavorites(node as Folder)
+ })
+
+ /**
+ * Sort the favorites paths array and
+ * update the order property of the existing views
+ */
+ const updateAndSortViews = function() {
+ 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) {
+ view.order = index
+ }
+ })
+ }
+
+ // Add a folder to the favorites paths array and update the views
+ const addToFavorites = function(node: Folder) {
+ const view = generateFavoriteFolderView(node)
+
+ // Skip if already exists
+ if (favoriteFolders.find((folder) => folder.path === node.path)) {
+ return
+ }
+
+ // Update arrays
+ favoriteFolders.push(node)
+ favoriteFoldersViews.push(view)
+
+ // Update and sort views
+ updateAndSortViews()
+ Navigation.register(view)
+ }
+
+ // Remove a folder from the favorites paths array and update the views
+ const removePathFromFavorites = function(path: string) {
+ const id = generateIdFromPath(path)
+ const index = favoriteFolders.findIndex((folder) => folder.path === path)
+
+ // Skip if not exists
+ if (index === -1) {
+ return
+ }
+
+ // Update arrays
+ favoriteFolders.splice(index, 1)
+ favoriteFoldersViews.splice(index, 1)
+
+ // Update and sort views
+ Navigation.remove(id)
+ updateAndSortViews()
+ }
+
+ // Update a folder from the favorites paths array and update the views
+ const updateNodeFromFavorites = function(node: Folder) {
+ const favoriteFolder = favoriteFolders.find((folder) => folder.fileid === node.fileid)
+
+ // Skip if it does not exists
+ if (favoriteFolder === undefined) {
+ return
+ }
+
+ removePathFromFavorites(favoriteFolder.path)
+ addToFavorites(node)
+ }
+
+ updateAndSortViews()
+}
diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts
new file mode 100644
index 00000000000..a94aab0f14b
--- /dev/null
+++ b/apps/files/src/views/files.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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 = ''
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: VIEW_ID,
+ name: t('files', 'All files'),
+ caption: t('files', 'List of your files and folders.'),
+
+ icon: FolderSvg,
+ // 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
new file mode 100644
index 00000000000..2ce4e501e6f
--- /dev/null
+++ b/apps/files/src/views/folderTree.ts
@@ -0,0 +1,176 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { TreeNode } from '../services/FolderTree.ts'
+
+import PQueue from 'p-queue'
+import { FileType, Folder, Node, View, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { emit, subscribe } from '@nextcloud/event-bus'
+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-outline.svg?raw'
+
+import {
+ folderTreeId,
+ getContents,
+ getFolderTreeNodes,
+ getSourceParent,
+ sourceRoot,
+} from '../services/FolderTree.ts'
+
+const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree
+
+let showHiddenFiles = loadState('files', 'config', { show_hidden: false }).show_hidden
+
+const Navigation = getNavigation()
+
+const queue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
+
+const registerQueue = new PQueue({ concurrency: 5, intervalCap: 5, interval: 200 })
+
+const registerTreeChildren = async (path: string = '/') => {
+ await queue.add(async () => {
+ const nodes = await getFolderTreeNodes(path)
+ const promises = nodes.map(node => registerQueue.add(() => registerNodeView(node)))
+ await Promise.allSettled(promises)
+ })
+}
+
+const getLoadChildViews = (node: TreeNode | Folder) => {
+ return async (view: View): Promise<void> => {
+ // @ts-expect-error Custom property on View instance
+ if (view.loading || view.loaded) {
+ return
+ }
+ // @ts-expect-error Custom property
+ view.loading = true
+ await registerTreeChildren(node.path)
+ // @ts-expect-error Custom property
+ view.loading = false
+ // @ts-expect-error Custom property
+ view.loaded = true
+ // @ts-expect-error No payload
+ emit('files:navigation:updated')
+ // @ts-expect-error No payload
+ emit('files:folder-tree:expanded')
+ }
+}
+
+const registerNodeView = (node: TreeNode | Folder) => {
+ const registeredView = Navigation.views.find(view => view.id === node.encodedSource)
+ if (registeredView) {
+ Navigation.remove(registeredView.id)
+ }
+ if (!showHiddenFiles && node.basename.startsWith('.')) {
+ return
+ }
+ Navigation.register(new View({
+ id: node.encodedSource,
+ parent: getSourceParent(node.source),
+
+ // @ts-expect-error Casing differences
+ name: node.displayName ?? node.displayname ?? node.basename,
+
+ icon: FolderSvg,
+
+ getContents,
+ loadChildViews: getLoadChildViews(node),
+
+ params: {
+ view: folderTreeId,
+ fileid: String(node.fileid), // Needed for matching exact routes
+ dir: node.path,
+ },
+ }))
+}
+
+const removeFolderView = (folder: Folder) => {
+ const viewId = folder.encodedSource
+ Navigation.remove(viewId)
+}
+
+const removeFolderViewSource = (source: string) => {
+ Navigation.remove(source)
+}
+
+const onCreateNode = (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ registerNodeView(node)
+}
+
+const onDeleteNode = (node: Node) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ removeFolderView(node)
+}
+
+const onMoveNode = ({ node, oldSource }) => {
+ if (node.type !== FileType.Folder) {
+ return
+ }
+ removeFolderViewSource(oldSource)
+ registerNodeView(node)
+
+ const newPath = node.source.replace(sourceRoot, '')
+ const oldPath = oldSource.replace(sourceRoot, '')
+ const childViews = Navigation.views.filter(view => {
+ if (!view.params?.dir) {
+ return false
+ }
+ if (isSamePath(view.params.dir, oldPath)) {
+ return false
+ }
+ return view.params.dir.startsWith(oldPath)
+ })
+ for (const view of childViews) {
+ // @ts-expect-error FIXME Allow setting parent
+ view.parent = getSourceParent(node.source)
+ // @ts-expect-error dir param is defined
+ view.params.dir = view.params.dir.replace(oldPath, newPath)
+ }
+}
+
+const onUserConfigUpdated = async ({ key, value }) => {
+ if (key === 'show_hidden') {
+ showHiddenFiles = value
+ await registerTreeChildren()
+ // @ts-expect-error No payload
+ emit('files:folder-tree:initialized')
+ }
+}
+
+const registerTreeRoot = () => {
+ Navigation.register(new View({
+ id: folderTreeId,
+
+ name: t('files', 'Folder tree'),
+ caption: t('files', 'List of your files and folders.'),
+
+ icon: FolderMultipleSvg,
+ order: 50, // Below all other views
+
+ getContents,
+ }))
+}
+
+export const registerFolderTreeView = async () => {
+ if (!isFolderTreeEnabled) {
+ return
+ }
+ registerTreeRoot()
+ await registerTreeChildren()
+ subscribe('files:node:created', onCreateNode)
+ subscribe('files:node:deleted', onDeleteNode)
+ subscribe('files:node:moved', onMoveNode)
+ subscribe('files:config:updated', onUserConfigUpdated)
+ // @ts-expect-error No payload
+ emit('files:folder-tree:initialized')
+}
diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts
new file mode 100644
index 00000000000..241582057d1
--- /dev/null
+++ b/apps/files/src/views/personal-files.ts
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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'
+
+/**
+ * Register the personal files view if allowed
+ */
+export function registerPersonalFilesView(): void {
+ if (!hasPersonalFilesView()) {
+ return
+ }
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ 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,
+ // 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/recent.ts b/apps/files/src/views/recent.ts
new file mode 100644
index 00000000000..fda1d99e13d
--- /dev/null
+++ b/apps/files/src/views/recent.ts
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { View, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import HistorySvg from '@mdi/svg/svg/history.svg?raw'
+
+import { getContents } from '../services/Recent'
+
+export default () => {
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: 'recent',
+ name: t('files', 'Recent'),
+ caption: t('files', 'List of recently modified files and folders.'),
+
+ emptyTitle: t('files', 'No recently modified files'),
+ emptyCaption: t('files', 'Files and folders you recently modified will show up here.'),
+
+ icon: HistorySvg,
+ order: 10,
+
+ defaultSortKey: 'mtime',
+
+ 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,
+ }))
+}