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.vue23
-rw-r--r--apps/files/src/views/FilesList.vue651
-rw-r--r--apps/files/src/views/Navigation.cy.ts135
-rw-r--r--apps/files/src/views/Navigation.vue265
-rw-r--r--apps/files/src/views/ReferenceFileWidget.vue2
-rw-r--r--apps/files/src/views/SearchEmptyView.vue53
-rw-r--r--apps/files/src/views/Settings.vue300
-rw-r--r--apps/files/src/views/Sidebar.vue147
-rw-r--r--apps/files/src/views/TemplatePicker.vue56
-rw-r--r--apps/files/src/views/favorites.spec.ts172
-rw-r--r--apps/files/src/views/favorites.ts79
-rw-r--r--apps/files/src/views/files.ts54
-rw-r--r--apps/files/src/views/folderTree.ts176
-rw-r--r--apps/files/src/views/personal-files.ts26
-rw-r--r--apps/files/src/views/search.ts51
17 files changed, 1825 insertions, 618 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
index c2a502ee1a8..b4d4bc54f14 100644
--- a/apps/files/src/views/FileReferencePickerElement.vue
+++ b/apps/files/src/views/FileReferencePickerElement.vue
@@ -39,7 +39,7 @@ export default defineComponent({
},
filepickerOptions() {
return {
- allowPickDirectory: false,
+ allowPickDirectory: true,
buttons: this.buttonFactory,
container: `#${this.containerId}`,
multiselect: false,
@@ -53,18 +53,17 @@ export default defineComponent({
buttonFactory(selected: NcNode[]): IFilePickerButton[] {
const buttons = [] as IFilePickerButton[]
if (selected.length === 0) {
- buttons.push({
- label: t('files', 'Choose file'),
- type: 'tertiary' as never,
- callback: this.onClose,
- })
- } else {
- buttons.push({
- label: t('files', 'Choose {file}', { file: selected[0].basename }),
- type: 'primary',
- callback: this.onClose,
- })
+ 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
},
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index ae20c58ea32..f9e517e92ee 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -4,12 +4,12 @@
-->
<template>
<NcAppContent :page-heading="pageHeading" data-cy-files-content>
- <div class="files-list__header">
+ <div class="files-list__header" :class="{ 'files-list__header--public': isPublic }">
<!-- Current folder breadcrumbs -->
- <BreadCrumbs :path="dir" @reload="fetchContent">
+ <BreadCrumbs :path="directory" @reload="fetchContent">
<template #actions>
<!-- Sharing button -->
- <NcButton v-if="canShare && filesListWidth >= 512"
+ <NcButton v-if="canShare && fileListWidth >= 512"
:aria-label="shareButtonLabel"
:class="{ 'files-list__header-share-button--shared': shareButtonType }"
:title="shareButtonLabel"
@@ -17,36 +17,47 @@
type="tertiary"
@click="openSharingSidebar">
<template #icon>
- <LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
+ <LinkIcon v-if="shareButtonType === ShareType.Link" />
<AccountPlusIcon v-else :size="20" />
</template>
</NcButton>
- <!-- Disabled upload button -->
- <NcButton v-if="!canUpload || isQuotaExceeded"
- :aria-label="cantUploadLabel"
- :title="cantUploadLabel"
- class="files-list__header-upload-button--disabled"
- :disabled="true"
- type="secondary">
- <template #icon>
- <PlusIcon :size="20" />
- </template>
- {{ t('files', 'New') }}
- </NcButton>
-
<!-- Uploader -->
- <UploadPicker v-else-if="currentFolder"
- :content="dirContents"
- :destination="currentFolder"
- :multiple="true"
+ <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>
- <NcButton v-if="filesListWidth >= 512 && enableGridView"
+ <!-- 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"
@@ -57,100 +68,141 @@
<ViewGridIcon v-else />
</template>
</NcButton>
-
- <!-- Secondary loading indicator -->
- <NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
</div>
<!-- Drag and drop notice -->
- <DragAndDropNotice v-if="!loading && canUpload" :current-folder="currentFolder" />
-
- <!-- Initial loading -->
- <NcLoadingIcon v-if="loading && !isRefreshing"
+ <DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" />
+
+ <!--
+ Initial current view loading0. This should never happen,
+ views are supposed to be registered far earlier in the lifecycle.
+ In case the URL is bad or a view is missing, we show a loading icon.
+ -->
+ <NcLoadingIcon v-if="!currentView"
class="files-list__loading-icon"
:size="38"
:name="t('files', 'Loading current folder')" />
- <!-- Empty content placeholder -->
- <NcEmptyContent v-else-if="!loading && isEmptyDir"
- :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="dir !== '/'" #action>
- <!-- Uploader -->
- <UploadPicker v-if="currentFolder && canUpload && !isQuotaExceeded"
- :content="dirContents"
- :destination="currentFolder"
- class="files-list__header-upload-button"
- multiple
- @failed="onUploadFail"
- @uploaded="onUpload" />
- <NcButton v-else
- :aria-label="t('files', 'Go to the previous folder')"
- :to="toPreviousDir"
- type="primary">
- {{ t('files', 'Go back') }}
- </NcButton>
- </template>
- <template #icon>
- <NcIconSvgWrapper :svg="currentView.icon" />
- </template>
- </NcEmptyContent>
-
- <!-- File list -->
+ <!-- File list - always mounted -->
<FilesListVirtual v-else
ref="filesListVirtual"
:current-folder="currentFolder"
:current-view="currentView"
- :nodes="dirContentsSorted" />
+ :nodes="dirContentsSorted"
+ :summary="summary">
+ <template #empty>
+ <!-- Initial loading -->
+ <NcLoadingIcon v-if="loading && !isRefreshing"
+ class="files-list__loading-icon"
+ :size="38"
+ :name="t('files', 'Loading current folder')" />
+
+ <!-- Empty due to error -->
+ <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error>
+ <template #action>
+ <NcButton type="secondary" @click="fetchContent">
+ <template #icon>
+ <IconReload :size="20" />
+ </template>
+ {{ t('files', 'Retry') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <IconAlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+
+ <!-- Custom empty view -->
+ <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper">
+ <div ref="customEmptyView" />
+ </div>
+
+ <!-- Default empty directory view -->
+ <NcEmptyContent v-else
+ :name="currentView?.emptyTitle || t('files', 'No files in here')"
+ :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
+ data-cy-files-content-empty>
+ <template v-if="directory !== '/'" #action>
+ <!-- Uploader -->
+ <UploadPicker v-if="canUpload && !isQuotaExceeded"
+ allow-folders
+ class="files-list__header-upload-button"
+ :content="getContent"
+ :destination="currentFolder"
+ :forbidden-characters="forbiddenCharacters"
+ multiple
+ @failed="onUploadFail"
+ @uploaded="onUpload" />
+ <NcButton v-else :to="toPreviousDir" type="primary">
+ {{ t('files', 'Go back') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :svg="currentView?.icon" />
+ </template>
+ </NcEmptyContent>
+ </template>
+ </FilesListVirtual>
</NcAppContent>
</template>
<script lang="ts">
-import type { View, ContentsWithRoot } from '@nextcloud/files'
+import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
import type { CancelablePromise } from 'cancelable-promise'
import type { ComponentPublicInstance } from 'vue'
import type { Route } from 'vue-router'
import type { UserConfig } from '../types.ts'
+import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { Folder, Node, Permission } from '@nextcloud/files'
+import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files'
+import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { translate as t } from '@nextcloud/l10n'
-import { join, dirname } from 'path'
-import { showError } from '@nextcloud/dialogs'
-import { Type } from '@nextcloud/sharing'
-import { UploadPicker } from '@nextcloud/upload'
+import { join, dirname, normalize, relative } from 'path'
+import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
+import { ShareType } from '@nextcloud/sharing'
+import { UploadPicker, UploadStatus } from '@nextcloud/upload'
import { loadState } from '@nextcloud/initial-state'
+import { useThrottleFn } from '@vueuse/core'
import { defineComponent } from 'vue'
+import NcAppContent from '@nextcloud/vue/components/NcAppContent'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import AccountPlusIcon from 'vue-material-design-icons/AccountPlusOutline.vue'
+import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import IconReload from 'vue-material-design-icons/Reload.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
-import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import PlusIcon from 'vue-material-design-icons/Plus.vue'
-import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
-import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
+import ViewGridIcon from 'vue-material-design-icons/ViewGridOutline.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { 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 { orderBy } from '../services/SortingService.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 filesListWidthMixin from '../mixins/filesListWidth.ts'
import filesSortingMixin from '../mixins/filesSorting.ts'
-import logger from '../logger.js'
-import DragAndDropNotice from '../components/DragAndDropNotice.vue'
-import debounce from 'debounce'
+import logger from '../logger.ts'
const isSharingEnabled = (getCapabilities() as { files_sharing?: boolean })?.files_sharing !== undefined
@@ -164,23 +216,38 @@ export default defineComponent({
LinkIcon,
ListViewIcon,
NcAppContent,
+ NcActions,
+ NcActionButton,
NcButton,
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
- PlusIcon,
AccountPlusIcon,
UploadPicker,
ViewGridIcon,
+ IconAlertCircleOutline,
+ IconReload,
},
mixins: [
- filesListWidthMixin,
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()
@@ -188,167 +255,142 @@ export default defineComponent({
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,
- enableGridView,
// non reactive data
- Type,
+ enableGridView,
+ forbiddenCharacters,
+ ShareType,
}
},
data() {
return {
- filterText: '',
loading: true,
+ loadingAction: null as string | null,
+ error: null as string | null,
promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
- unsubscribeStoreCallback: () => {},
+ dirContentsFiltered: [] as INode[],
}
},
computed: {
/**
- * Handle search event from unified search.
+ * Get a callback function for the uploader to fetch directory contents for conflict resolution
*/
- onSearch() {
- return debounce((searchEvent: { query: string }) => {
- console.debug('Files app handling search event from unified search...', searchEvent)
- this.filterText = searchEvent.query
- }, 500)
+ 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
},
- currentView(): View {
- return this.$navigation.active || this.$navigation.views.find((view) => view.id === (this.$route.params?.view ?? 'files'))!
- },
-
pageHeading(): string {
- return this.currentView?.name ?? t('files', 'Files')
- },
+ const title = this.currentView?.name ?? t('files', 'Files')
- /**
- * The current directory query.
- */
- dir(): string {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
-
- /**
- * The current file id
- */
- fileId(): number | null {
- const number = Number.parseInt(this.$route?.params.fileid ?? '')
- return Number.isNaN(number) ? null : number
+ if (this.currentFolder === undefined || this.directory === '/') {
+ return title
+ }
+ return `${this.currentFolder.displayname} - ${title}`
},
/**
* The current folder.
*/
- currentFolder(): Folder | undefined {
- if (!this.currentView?.id) {
- return
- }
-
- if (this.dir === '/') {
- return this.filesStore.getRoot(this.currentView.id)
- }
+ currentFolder(): Folder {
+ // Temporary fake folder to use until we have the first valid folder
+ // fetched and cached. This allow us to mount the FilesListVirtual
+ // at all time and avoid unmount/mount and undesired rendering issues.
+ const dummyFolder = new Folder({
+ id: 0,
+ source: getRemoteURL() + getRootPath(),
+ root: getRootPath(),
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.NONE,
+ })
- const source = this.pathsStore.getPath(this.currentView.id, this.dir)
- if (source === undefined) {
- return
+ if (!this.currentView?.id) {
+ return dummyFolder
}
- return this.filesStore.getNode(source) as Folder
+ return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
},
- /**
- * Directory content sorting parameters
- * Provided by an extra computed property for caching
- */
- sortingParameters() {
- const identifiers = [
- // 1: Sort favorites first if enabled
- ...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
- // 2: Sort folders first if sorting by name
- ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []),
- // 3: Use sorting mode if NOT basename (to be able to use displayName too)
- ...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
- // 4: Use displayName if available, fallback to name
- v => v.attributes?.displayName || v.basename,
- // 5: Finally, use basename if all previous sorting methods failed
- v => v.basename,
- ]
- const orders = [
- // (for 1): always sort favorites before normal files
- ...(this.userConfig.sort_favorites_first ? ['asc'] : []),
- // (for 2): always sort folders before files
- ...(this.userConfig.sort_folders_first ? ['asc'] : []),
- // (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
- ...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
- // (also for 3 so make sure not to conflict with 2 and 3)
- ...(this.sortingMode !== 'mtime' && this.sortingMode !== 'basename' ? [this.isAscSorting ? 'asc' : 'desc'] : []),
- // for 4: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- // for 5: use configured sorting direction
- this.isAscSorting ? 'asc' : 'desc',
- ] as ('asc'|'desc')[]
- return [identifiers, orders] as const
+ dirContents(): Node[] {
+ return (this.currentFolder?._children || [])
+ .map(this.filesStore.getNode)
+ .filter((node: Node) => !!node)
},
/**
* The current directory contents.
*/
- dirContentsSorted(): Node[] {
+ dirContentsSorted(): INode[] {
if (!this.currentView) {
return []
}
- let filteredDirContent = [...this.dirContents]
- // Filter based on the filterText obtained from nextcloud:unified-search.search event.
- if (this.filterText) {
- filteredDirContent = filteredDirContent.filter(node => {
- return node.basename.toLowerCase().includes(this.filterText.toLowerCase())
- })
- console.debug('Files view filtered', filteredDirContent)
- }
-
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.dirContents].sort(customColumn.sort)
+ const results = [...this.dirContentsFiltered].sort(customColumn.sort)
return this.isAscSorting ? results : results.reverse()
}
- return orderBy(
- filteredDirContent,
- ...this.sortingParameters,
- )
- },
-
- dirContents(): Node[] {
- const showHidden = this.userConfigStore?.userConfig.show_hidden
- return (this.currentFolder?._children || [])
- .map(this.getNode)
- .filter(file => {
- if (!showHidden) {
- return file && file?.attributes?.hidden !== true && !file?.basename.startsWith('.')
+ 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 !!file
+ return 1
})
+ }
+
+ return nodes
},
/**
@@ -373,37 +415,37 @@ export default defineComponent({
* Route to the previous directory.
*/
toPreviousDir(): Route {
- const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
+ const dir = this.directory.split('/').slice(0, -1).join('/') || '/'
return { ...this.$route, query: { dir } }
},
- shareAttributes(): number[] | undefined {
+ 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.shareAttributes) {
+ if (!this.shareTypesAttributes) {
return t('files', 'Share')
}
- if (this.shareButtonType === Type.SHARE_TYPE_LINK) {
+ if (this.shareButtonType === ShareType.Link) {
return t('files', 'Shared by link')
}
return t('files', 'Shared')
},
- shareButtonType(): Type | null {
- if (!this.shareAttributes) {
+ shareButtonType(): ShareType | null {
+ if (!this.shareTypesAttributes) {
return null
}
// If all types are links, show the link icon
- if (this.shareAttributes.some(type => type === Type.SHARE_TYPE_LINK)) {
- return Type.SHARE_TYPE_LINK
+ if (this.shareTypesAttributes.some(type => type === ShareType.Link)) {
+ return ShareType.Link
}
- return Type.SHARE_TYPE_USER
+ return ShareType.User
},
gridViewButtonLabel() {
@@ -421,23 +463,72 @@ export default defineComponent({
isQuotaExceeded() {
return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
},
- cantUploadLabel() {
- if (this.isQuotaExceeded) {
- return t('files', 'Your have used your space quota and cannot upload files anymore')
- }
- return t('files', 'You don’t have permission to upload or create files here')
- },
/**
* Check if current folder has share permissions
*/
canShare() {
- return isSharingEnabled
+ 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
@@ -445,15 +536,16 @@ export default defineComponent({
logger.debug('View changed', { newView, oldView })
this.selectionStore.reset()
- this.resetSearch()
this.fetchContent()
},
- dir(newDir, oldDir) {
+ directory(newDir, oldDir) {
logger.debug('Directory changed', { newDir, oldDir })
// TODO: preserve selection on browsing?
this.selectionStore.reset()
- this.resetSearch()
+ if (window.OCA.Files.Sidebar?.close) {
+ window.OCA.Files.Sidebar.close()
+ }
this.fetchContent()
// Scroll to top, force virtual scroller to re-render
@@ -466,42 +558,73 @@ export default defineComponent({
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()
},
},
- mounted() {
- this.fetchContent()
-
+ async mounted() {
subscribe('files:node:deleted', this.onNodeDeleted)
subscribe('files:node:updated', this.onUpdatedNode)
- subscribe('nextcloud:unified-search.search', this.onSearch)
- subscribe('nextcloud:unified-search.reset', this.onSearch)
// reload on settings change
- this.unsubscribeStoreCallback = this.userConfigStore.$subscribe(() => this.fetchContent(), { deep: true })
+ 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('nextcloud:unified-search.search', this.onSearch)
- unsubscribe('nextcloud:unified-search.reset', this.onSearch)
- this.unsubscribeStoreCallback()
+ unsubscribe('files:config:updated', this.fetchContent)
+ unsubscribe('files:filters:changed', this.filterDirContent)
+ unsubscribe('files:search:updated', this.onUpdateSearch)
},
methods: {
- t,
+ onUpdateSearch({ query, scope }) {
+ if (query && scope !== 'filter') {
+ this.debouncedFetchContent()
+ }
+ },
async fetchContent() {
this.loading = true
- const dir = this.dir
+ this.error = null
+ const dir = this.directory
const currentView = this.currentView
if (!currentView) {
- logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
+ logger.debug('The current view does not exists or is not ready.', { currentView })
+
+ // If we still haven't a valid view, let's wait for the page to load
+ // then try again. Else redirect to the default view
+ window.addEventListener('DOMContentLoaded', () => {
+ if (!this.currentView) {
+ logger.warn('No current view after DOMContentLoaded, redirecting to the default view')
+ window.OCP.Files.Router.goToRoute(null, { view: defaultView() })
+ }
+ }, { once: true })
return
}
+ logger.debug('Fetching contents for directory', { dir, currentView })
+
// If we have a cancellable promise ongoing, cancel it
if (this.promise && 'cancel' in this.promise) {
this.promise.cancel()
@@ -542,6 +665,7 @@ export default defineComponent({
})
} catch (error) {
logger.error('Error while fetching content', { error })
+ this.error = humanizeWebDAVError(error)
} finally {
this.loading = false
}
@@ -549,16 +673,6 @@ export default defineComponent({
},
/**
- * Get a cached note from the store
- *
- * @param {number} fileId the file id to get
- * @return {Folder|File}
- */
- getNode(fileId) {
- return this.filesStore.getNode(fileId)
- },
-
- /**
* Handle the node deleted event to reset open file
* @param node The deleted node
*/
@@ -566,10 +680,10 @@ export default defineComponent({
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 neeed to keept the current view but move to the parent directory
+ // in this case we need to keep the current view but move to the parent directory
window.OCP.Files.Router.goToRoute(
null,
- { view: this.$route.params.view },
+ { view: this.currentView!.id },
{ dir: this.currentFolder?.dirname ?? '/' },
)
} else {
@@ -590,8 +704,7 @@ export default defineComponent({
onUpload(upload: Upload) {
// Let's only refresh the current Folder
// Navigating to a different folder will refresh it anyway
- const destinationSource = dirname(upload.source)
- const needsRefresh = destinationSource === this.currentFolder?.source
+ 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
@@ -604,6 +717,11 @@ export default defineComponent({
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'))
@@ -652,13 +770,6 @@ export default defineComponent({
}
},
- /**
- * Reset the search query
- */
- resetSearch() {
- this.filterText = ''
- },
-
openSharingSidebar() {
if (!this.currentFolder) {
logger.debug('No current folder found for opening sharing sidebar')
@@ -668,16 +779,66 @@ export default defineComponent({
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
window.OCA.Files.Sidebar.setActiveTab('sharing')
}
- sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
+ 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;
@@ -698,6 +859,11 @@ export default defineComponent({
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
@@ -711,12 +877,29 @@ export default defineComponent({
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 44px;
- width: 44px;
- height: 44px;
+ flex: 0 0 var(--default-clickable-area);
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
}
&__loading-icon {
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index a555a04a910..7357943ee28 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -2,23 +2,44 @@
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import FolderSvg from '@mdi/svg/svg/folder.svg'
-import ShareSvg from '@mdi/svg/svg/share-variant.svg'
+import type { Navigation } from '@nextcloud/files'
+import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { createTestingPinia } from '@pinia/testing'
import NavigationView from './Navigation.vue'
-import router from '../router/router'
import { useViewConfigStore } from '../store/viewConfig'
import { Folder, View, getNavigation } from '@nextcloud/files'
-import Vue from 'vue'
+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,
+})
-describe('Navigation renders', () => {
- delete window._nc_navigation
- const Navigation = getNavigation()
+function mockWindow() {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router = new RouterService(router)
+}
- before(() => {
- Vue.prototype.$navigation = Navigation
+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,
@@ -30,6 +51,7 @@ describe('Navigation renders', () => {
it('renders', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -44,29 +66,31 @@ describe('Navigation renders', () => {
})
describe('Navigation API', () => {
- delete window._nc_navigation
- const Navigation = getNavigation()
+ let Navigation: Navigation
+
+ before(async () => {
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ mockWindow()
- before(() => {
- Vue.prototype.$navigation = Navigation
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
+ beforeEach(() => resetNavigation())
+
it('Check API entries rendering', () => {
- Navigation.register(new View({
- id: 'files',
- name: 'Files',
- getContents: async () => ({ folder: {} as Folder, contents: [] }),
- icon: FolderSvg,
- order: 1,
- }))
+ Navigation.register(createView('files', 'Files'))
+ console.warn(Navigation.views)
cy.mount(NavigationView, {
+ router,
global: {
- plugins: [createTestingPinia({
- createSpy: cy.spy,
- })],
+ plugins: [
+ createTestingPinia({
+ createSpy: cy.spy,
+ }),
+ ],
},
- router,
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -76,21 +100,16 @@ describe('Navigation API', () => {
})
it('Adds a new entry and render', () => {
- Navigation.register(new View({
- id: 'sharing',
- name: 'Sharing',
- getContents: async () => ({ folder: {} as Folder, contents: [] }),
- icon: ShareSvg,
- order: 2,
- }))
+ Navigation.register(createView('files', 'Files'))
+ Navigation.register(createView('sharing', 'Sharing'))
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
- router,
})
cy.get('[data-cy-files-navigation]').should('be.visible')
@@ -100,22 +119,17 @@ describe('Navigation API', () => {
})
it('Adds a new children, render and open menu', () => {
- Navigation.register(new View({
- id: 'sharingin',
- name: 'Shared with me',
- getContents: async () => ({ folder: {} as Folder, contents: [] }),
- 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, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
- router,
})
cy.wrap(useViewConfigStore()).as('viewConfigStore')
@@ -143,30 +157,25 @@ describe('Navigation API', () => {
})
it('Throws when adding a duplicate entry', () => {
- expect(() => {
- Navigation.register(new View({
- id: 'files',
- name: 'Files',
- getContents: async () => ({ folder: {} as Folder, contents: [] }),
- icon: FolderSvg,
- order: 1,
- }))
- }).to.throw('View 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', () => {
- delete window._nc_navigation
- const Navigation = getNavigation()
-
- before(() => {
- Vue.prototype.$navigation = Navigation
+ before(async () => {
+ delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -181,9 +190,11 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: -1,
+ total: 50 * 1024 * 1024 * 1024,
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -200,10 +211,12 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: 5 * 1024 * 1024 * 1024,
+ total: 5 * 1024 * 1024 * 1024,
relative: 20, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -213,18 +226,21 @@ describe('Quota rendering', () => {
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.mockInitialState('files', 'storageStats', {
used: 5 * 1024 * 1024 * 1024,
quota: 1024 * 1024 * 1024,
+ total: 1024 * 1024 * 1024,
relative: 500, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -234,7 +250,8 @@ describe('Quota rendering', () => {
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 71e9bf38068..0f3c3647c6e 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -4,34 +4,21 @@
-->
<template>
<NcAppNavigation data-cy-files-navigation
+ class="files-navigation"
:aria-label="t('files', 'Files')">
- <template #list>
- <NcAppNavigationItem v-for="view in parentViews"
- :key="view.id"
- :allow-collapse="true"
- :data-cy-files-navigation-item="view.id"
- :exact="useExactRouteMatching(view)"
- :icon="view.iconClass"
- :name="view.name"
- :open="isExpanded(view)"
- :pinned="view.sticky"
- :to="generateToNavigation(view)"
- @update:open="onToggleExpand(view)">
- <!-- Sanitized icon as svg if provided -->
- <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
-
- <!-- Child views if any -->
- <NcAppNavigationItem v-for="child in childViews[view.id]"
- :key="child.id"
- :data-cy-files-navigation-item="child.id"
- :exact-path="true"
- :icon="child.iconClass"
- :name="child.name"
- :to="generateToNavigation(child)">
- <!-- Sanitized icon as svg if provided -->
- <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" />
- </NcAppNavigationItem>
- </NcAppNavigationItem>
+ <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 -->
@@ -41,52 +28,73 @@
<NavigationQuota />
<!-- Files settings modal toggle-->
- <NcAppNavigationItem :aria-label="t('files', 'Open the files app settings')"
- :name="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 lang="ts">
import type { View } from '@nextcloud/files'
+import type { ViewConfig } from '../types.ts'
-import { emit } from '@nextcloud/event-bus'
-import { translate } from '@nextcloud/l10n'
-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'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
-import { useViewConfigStore } from '../store/viewConfig.ts'
-import logger from '../logger.js'
+import IconCog from 'vue-material-design-icons/CogOutline.vue'
+import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
+import FilesNavigationItem from '../components/FilesNavigationItem.vue'
+import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
+
+import { useNavigation } from '../composables/useNavigation'
+import { 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,
- NcIconSvgWrapper,
+ NcAppNavigationList,
SettingsModal,
},
setup() {
+ const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
+ const { currentView, views } = useNavigation()
+
return {
+ currentView,
+ t,
+ views,
+
+ filtersStore,
viewConfigStore,
}
},
@@ -98,117 +106,80 @@ export default {
},
computed: {
+ /**
+ * The current view ID from the route params
+ */
currentViewId() {
return this.$route?.params?.view || 'files'
},
- currentView(): View {
- return this.views.find(view => view.id === this.currentViewId)!
- },
-
- views(): View[] {
- return this.$navigation.views
- },
-
- parentViews(): View[] {
- return this.views
- // filter child views
- .filter(view => !view.parent)
- // sort views by order
- .sort((a, b) => {
- return a.order - b.order
- })
- },
-
- childViews(): Record<string, View[]> {
+ /**
+ * 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) {
- if (view.id !== oldView?.id) {
- this.$navigation.setActive(view)
- logger.debug(`Navigation changed from ${oldView.id} to ${view.id}`, { from: oldView, to: view })
-
+ 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 })
}
},
},
+ created() {
+ subscribe('files:folder-tree:initialized', this.loadExpandedViews)
+ subscribe('files:folder-tree:expanded', this.loadExpandedViews)
+ },
+
beforeMount() {
- if (this.currentView) {
- logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
- this.showView(this.currentView)
- }
+ // 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: {
- /**
- * Only use exact route matching on routes with child views
- * Because if a view does not have children (like the files view) then multiple routes might be matched for it
- * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view
- * @param view The view to check
- */
- useExactRouteMatching(view: View): boolean {
- return this.childViews[view.id]?.length > 0
+ 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)
+ }
},
+ /**
+ * Set the view as active on the navigation and handle internal state
+ * @param view View to set active
+ */
showView(view: View) {
// Closing any opened sidebar
- window?.OCA?.Files?.Sidebar?.close?.()
- this.$navigation.setActive(view)
+ window.OCA?.Files?.Sidebar?.close?.()
+ getNavigation().setActive(view)
emit('files:navigation:changed', view)
},
/**
- * Expand/collapse a a view with children and permanently
- * save this setting in the server.
- * @param view View to toggle
- */
- onToggleExpand(view: View) {
- // Invert state
- const isExpanded = this.isExpanded(view)
- // Update the view expanded state, might not be necessary
- view.expanded = !isExpanded
- this.viewConfigStore.update(view.id, 'expanded', !isExpanded)
- },
-
- /**
- * Check if a view is expanded by user config
- * or fallback to the default value.
- * @param view View to check if expanded
- */
- isExpanded(view: View): boolean {
- return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
- ? this.viewConfigStore.getConfig(view.id).expanded === true
- : view.expanded === true
- },
-
- /**
- * Generate the route to a view
- * @param view View to generate "to" navigation for
- */
- generateToNavigation(view: View) {
- if (view.params) {
- const { dir } = view.params
- return { name: 'filelist', params: view.params, query: { dir } }
- }
- return { name: 'filelist', params: { view: view.id } }
- },
-
- /**
* Open the settings modal
*/
openSettings() {
@@ -221,26 +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::v-deep .app-navigation-entry.active .button-vue.icon-collapse:not(:hover) {
- color: var(--color-primary-element-text);
-}
-
-.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 {
@@ -250,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
index 41b5fe73048..9db346ea35d 100644
--- a/apps/files/src/views/ReferenceFileWidget.vue
+++ b/apps/files/src/views/ReferenceFileWidget.vue
@@ -256,7 +256,7 @@ export default defineComponent({
min-width: 88px;
max-width: 88px;
padding: 12px;
- padding-right: 0;
+ padding-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
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 f71a5bc0f7c..bfac8e0b3d6 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -8,7 +8,27 @@
:name="t('files', 'Files settings')"
@update:open="onClose">
<!-- Settings API-->
- <NcAppSettingsSection id="settings" :name="t('files', 'Files settings')">
+ <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)">
@@ -19,22 +39,35 @@
@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 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>
- <NcCheckboxRadioSwitch v-if="enableGridView"
- data-cy-files-settings-setting="grid_view"
- :checked="userConfig.grid_view"
- @update:checked="setConfig('grid_view', $event)">
- {{ t('files', 'Enable the grid view') }}
- </NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<!-- Settings API-->
@@ -52,8 +85,9 @@
: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()"
@@ -67,33 +101,205 @@
:href="webdavDocs"
target="_blank"
rel="noreferrer noopener">
- {{ t('files', 'Use this address to access your Files via WebDAV') }} ↗
+ {{ t('files', 'How to access files using WebDAV') }} ↗
</a>
</em>
<br>
- <em>
+ <em v-if="isTwoFactorEnabled">
<a class="setting-link" :href="appPasswordUrl">
- {{ t('files', 'If you have enabled 2FA, you must create and use a new app password by clicking here.') }} ↗
+ {{ t('files', 'Two-Factor Authentication is enabled for your account, and therefore you need to use an app password to connect an external WebDAV client.') }} ↗
</a>
</em>
</NcAppSettingsSection>
+
+ <NcAppSettingsSection id="warning" :name="t('files', 'Warnings')">
+ <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.js'
-import Setting from '../components/Setting.vue'
-
-import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
+import { getCapabilities } from '@nextcloud/capabilities'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
+
+import Clipboard from 'vue-material-design-icons/ContentCopy.vue'
+import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
+import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+
import { useUserConfigStore } from '../store/userconfig.ts'
+import Setting from '../components/Setting.vue'
export default {
name: 'Settings',
@@ -115,8 +321,11 @@ export default {
setup() {
const userConfigStore = useUserConfigStore()
+ const isSystemtagsEnabled = getCapabilities()?.systemtags?.enabled === true
return {
+ isSystemtagsEnabled,
userConfigStore,
+ t,
}
},
@@ -131,6 +340,7 @@ export default {
appPasswordUrl: generateUrl('/settings/user/security#generate-app-token-section'),
webdavUrlCopied: false,
enableGridView: (loadState('core', 'config', [])['enable_non-accessible_features'] ?? true),
+ isTwoFactorEnabled: (loadState('files', 'isTwoFactorEnabled', false)),
}
},
@@ -138,6 +348,24 @@ export default {
userConfig() {
return this.userConfigStore.userConfig
},
+
+ sortedSettings() {
+ // Sort settings by name
+ return [...this.settings].sort((a, b) => {
+ if (a.order && b.order) {
+ return a.order - b.order
+ }
+ return a.name.localeCompare(b.name)
+ })
+ },
+ },
+
+ created() {
+ // ? opens the settings dialog on the keyboard shortcuts section
+ useHotKey('?', this.showKeyboardShortcuts, {
+ stop: true,
+ prevent: true,
+ })
},
beforeMount() {
@@ -170,19 +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 1facff4642d..40a16d42b42 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -17,12 +17,19 @@
@closing="handleClosing"
@closed="handleClosed">
<template v-if="fileInfo" #subname>
- <NcIconSvgWrapper v-if="fileInfo.isFavourited"
- :path="mdiStar"
- :name="t('files', 'Favorite')"
- inline />
- {{ size }}
- <NcDateTime :timestamp="fileInfo.mtime" />
+ <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? -->
@@ -30,8 +37,8 @@
<div class="sidebar__description">
<SystemTags v-if="isSystemTagsEnabled && showTagsDefault"
v-show="showTags"
- :file-id="fileInfo.id"
- @has-tags="value => showTags = value" />
+ :disabled="!fileInfo?.canEdit()"
+ :file-id="fileInfo.id" />
<LegacyView v-for="view in views"
:key="view.cid"
:component="view"
@@ -85,32 +92,35 @@
</template>
</NcAppSidebar>
</template>
-<script>
-import { getCurrentUser } from '@nextcloud/auth'
-import { getCapabilities } from '@nextcloud/capabilities'
-import { showError } from '@nextcloud/dialogs'
+<script lang="ts">
+import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files'
+import { defineComponent } from 'vue'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { File, Folder, formatFileSize } from '@nextcloud/files'
import { encodePath } from '@nextcloud/paths'
-import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
-import { Type as ShareTypes } from '@nextcloud/sharing'
+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 axios from '@nextcloud/axios'
+import { ShareType } from '@nextcloud/sharing'
+import { showError } from '@nextcloud/dialogs'
import $ from 'jquery'
+import axios from '@nextcloud/axios'
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
import FileInfo from '../services/FileInfo.js'
import LegacyView from '../components/LegacyView.vue'
import SidebarTab from '../components/SidebarTab.vue'
import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
-import logger from '../logger.js'
+import logger from '../logger.ts'
-export default {
+export default defineComponent({
name: 'Sidebar',
components: {
@@ -122,6 +132,7 @@ export default {
NcIconSvgWrapper,
SidebarTab,
SystemTags,
+ NcUserBubble,
},
setup() {
@@ -145,6 +156,7 @@ export default {
error: null,
loading: true,
fileInfo: null,
+ node: null,
isFullScreen: false,
hasLowHeight: false,
}
@@ -186,8 +198,7 @@ export default {
* @return {string}
*/
davPath() {
- const user = this.currentUser.uid
- return generateRemoteUrl(`dav/files/${user}${encodePath(this.file)}`)
+ return `${davRemoteURL}${davRootPath}${encodePath(this.file)}`
},
/**
@@ -234,8 +245,8 @@ export default {
},
compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen,
loading: this.loading,
- name: this.fileInfo.name,
- title: this.fileInfo.name,
+ name: this.node?.displayname ?? this.fileInfo.name,
+ title: this.node?.displayname ?? this.fileInfo.name,
}
} else if (this.error) {
return {
@@ -287,6 +298,25 @@ export default {
isSystemTagsEnabled() {
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)
@@ -345,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) {
@@ -374,10 +404,10 @@ export default {
},
/**
- * 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 {
@@ -400,17 +430,21 @@ export default {
*/
const isDir = this.fileInfo.type === 'dir'
const Node = isDir ? Folder : File
- emit(state ? 'files:favorites:added' : 'files:favorites:removed', new Node({
+ const node = new Node({
fileid: this.fileInfo.id,
- source: this.davPath,
- root: `/files/${getCurrentUser().uid}`,
+ 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) {
- showError(t('files', 'Unable to change the favourite state of the file'))
- logger.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 })
}
},
@@ -430,7 +464,10 @@ export default {
* Toggle the tags selector
*/
toggleTags() {
- this.showTagsDefault = this.showTags = !this.showTags
+ // toggle
+ this.showTags = !this.showTags
+ // save the new state
+ this.setShowTagsDefault(this.showTags)
},
/**
@@ -457,7 +494,8 @@ export default {
this.loading = true
try {
- this.fileInfo = await FileInfo(this.davPath)
+ 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('/')
@@ -475,7 +513,7 @@ export default {
await this.$nextTick()
- if (focusTabAfterLoad) {
+ if (focusTabAfterLoad && this.$refs.sidebar) {
this.$refs.sidebar.focusActiveTabContent()
}
} catch (error) {
@@ -550,7 +588,7 @@ export default {
this.hasLowHeight = document.documentElement.clientHeight < 1024
},
},
-}
+})
</script>
<style lang="scss" scoped>
.app-sidebar {
@@ -581,7 +619,7 @@ export default {
}
.svg-icon {
- ::v-deep svg {
+ :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
@@ -589,10 +627,25 @@ export default {
}
}
-.sidebar__description {
- display: flex;
- flex-direction: column;
- width: 100%;
- gap: 8px 0;
+.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 f2e2e29e4b5..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -17,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"
@@ -25,6 +27,7 @@
v-bind="template"
:checked="checked === template.fileid"
:ratio="provider.ratio"
+ @confirm-click="onConfirmClick"
@check="onCheck" />
</ul>
@@ -47,19 +50,20 @@
import type { TemplateFile } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
-import { showError } from '@nextcloud/dialogs'
+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 } from '../services/Templates.js'
+import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcModal from '@nextcloud/vue/components/NcModal'
import TemplatePreview from '../components/TemplatePreview.vue'
-import logger from '../logger.js'
+import TemplateFiller from '../components/TemplateFiller.vue'
+import logger from '../logger.ts'
const border = 2
const margin = 8
@@ -178,6 +182,11 @@ export default defineComponent({
// Else, open the picker
this.opened = true
+
+ // Set initial focus to the empty template preview
+ this.$nextTick(() => {
+ this.$refs.emptyTemplatePreview?.focus()
+ })
},
/**
@@ -200,8 +209,13 @@ export default defineComponent({
this.checked = fileid
},
- async onSubmit() {
- this.loading = true
+ 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
@@ -215,6 +229,7 @@ export default defineComponent({
normalize(`${currentDirectory}/${this.name}`),
this.selectedTemplate?.filename as string ?? '',
this.selectedTemplate?.templateType as string ?? '',
+ templateFields,
)
logger.debug('Created new file', fileInfo)
@@ -257,6 +272,27 @@ export default defineComponent({
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>
@@ -294,7 +330,7 @@ export default defineComponent({
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;
@@ -302,14 +338,14 @@ export default defineComponent({
}
// 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
index 7dbb0dbc551..f793eb9f54c 100644
--- a/apps/files/src/views/favorites.spec.ts
+++ b/apps/files/src/views/favorites.spec.ts
@@ -3,22 +3,27 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { basename } from 'path'
-import { expect } from '@jest/globals'
-import { Folder, Navigation, getNavigation } from '@nextcloud/files'
+
+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 eventBus from '@nextcloud/event-bus'
-import * as initialState from '@nextcloud/initial-state'
+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'
+import { registerFavoritesView } from './favorites'
+
+// eslint-disable-next-line import/namespace
+const { Folder, getNavigation } = filesUtils
-jest.mock('webdav/dist/node/request.js', () => ({
- request: jest.fn(),
-}))
+vi.mock('@nextcloud/axios')
-global.window.OC = {
+window.OC = {
+ ...window.OC,
TAG_FAVORITE: '_$!<Favorite>!$_',
}
@@ -31,25 +36,26 @@ declare global {
describe('Favorites view definition', () => {
let Navigation
beforeEach(() => {
- Navigation = getNavigation()
- expect(window._nc_navigation).toBeDefined()
- })
+ vi.resetAllMocks()
- afterEach(() => {
delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
})
- test('Default empty favorite view', () => {
- jest.spyOn(eventBus, 'subscribe')
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ 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: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
const favoritesView = Navigation.views.find(view => view.id === 'favorites')
const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
- expect(eventBus.subscribe).toHaveBeenCalledTimes(2)
+ 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)
@@ -59,40 +65,64 @@ describe('Favorites view definition', () => {
expect(favoritesView?.id).toBe('favorites')
expect(favoritesView?.name).toBe('Favorites')
expect(favoritesView?.caption).toBeDefined()
- expect(favoritesView?.icon).toBe('<svg>SvgMock</svg>')
+ expect(favoritesView?.icon).toMatch(/<svg.+<\/svg>/)
expect(favoritesView?.order).toBe(15)
expect(favoritesView?.columns).toStrictEqual([])
expect(favoritesView?.getContents).toBeDefined()
})
- test('Default with favorites', () => {
+ test('Default with favorites', async () => {
const favoriteFolders = [
- { fileid: 1, path: '/foo' },
- { fileid: 2, path: '/bar' },
- { fileid: 3, path: '/foo/bar' },
+ 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',
+ }),
]
- jest.spyOn(initialState, 'loadState').mockReturnValue(favoriteFolders)
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve(favoriteFolders))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
- registerFavoritesView()
+ 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(4)
+ expect(Navigation.views.length).toBe(5)
expect(favoritesView).toBeDefined()
- expect(favoriteFoldersViews.length).toBe(3)
+ expect(favoriteFoldersViews.length).toBe(4)
+
+ // Sorted by basename: bar, bar, foo
+ const expectedOrder = [2, 0, 1, 3]
favoriteFolders.forEach((folder, index) => {
const favoriteView = favoriteFoldersViews[index]
expect(favoriteView).toBeDefined()
expect(favoriteView?.id).toBeDefined()
expect(favoriteView?.name).toBe(basename(folder.path))
- expect(favoriteView?.icon).toBe('<svg>SvgMock</svg>')
- expect(favoriteView?.order).toBe(index)
+ expect(favoriteView?.icon).toMatch(/<svg.+<\/svg>/)
+ expect(favoriteView?.order).toBe(expectedOrder[index])
expect(favoriteView?.params).toStrictEqual({
dir: folder.path,
- fileid: folder.fileid.toString(),
+ fileid: String(folder.fileid),
view: 'favorites',
})
expect(favoriteView?.parent).toBe('favorites')
@@ -102,22 +132,21 @@ describe('Favorites view definition', () => {
})
})
-describe('Dynamic update of favourite folders', () => {
+describe('Dynamic update of favorite folders', () => {
let Navigation
beforeEach(() => {
- Navigation = getNavigation()
- })
+ vi.restoreAllMocks()
- afterEach(() => {
delete window._nc_navigation
+ Navigation = getNavigation()
})
test('Add a favorite folder creates a new entry in the navigation', async () => {
- jest.spyOn(eventBus, 'emit')
- jest.spyOn(initialState, 'loadState').mockReturnValue([])
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ vi.spyOn(eventBus, 'emit')
+ vi.spyOn(filesDavUtils, 'getFavoriteNodes').mockReturnValue(CancelablePromise.resolve([]))
+ vi.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as CFolder, contents: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
const favoritesView = Navigation.views.find(view => view.id === 'favorites')
const favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
@@ -129,7 +158,7 @@ describe('Dynamic update of favourite folders', () => {
// Create new folder to favorite
const folder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
})
@@ -141,12 +170,18 @@ describe('Dynamic update of favourite folders', () => {
})
test('Remove a favorite folder remove the entry from the navigation column', async () => {
- jest.spyOn(eventBus, 'emit')
- jest.spyOn(eventBus, 'subscribe')
- jest.spyOn(initialState, 'loadState').mockReturnValue([{ fileid: 42, path: '/Foo/Bar' }])
- jest.spyOn(favoritesService, 'getContents').mockReturnValue(CancelablePromise.resolve({ folder: {} as Folder, contents: [] }))
+ 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: [] }))
- registerFavoritesView()
+ await registerFavoritesView()
let favoritesView = Navigation.views.find(view => view.id === 'favorites')
let favoriteFoldersViews = Navigation.views.filter(view => view.parent === 'favorites')
@@ -158,7 +193,7 @@ describe('Dynamic update of favourite folders', () => {
// Create new folder to favorite
const folder = new Folder({
id: 1,
- source: 'http://localhost/remote.php/dav/files/admin/Foo/Bar',
+ source: 'http://nextcloud.local/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
attributes: {
@@ -166,11 +201,15 @@ describe('Dynamic update of favourite folders', () => {
},
})
+ 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')
@@ -180,4 +219,43 @@ describe('Dynamic update of favourite folders', () => {
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
index b246eb59793..cac776507ef 100644
--- a/apps/files/src/views/favorites.ts
+++ b/apps/files/src/views/favorites.ts
@@ -4,34 +4,30 @@
*/
import type { Folder, Node } from '@nextcloud/files'
-import { subscribe } from '@nextcloud/event-bus'
import { FileType, View, getNavigation } from '@nextcloud/files'
-import { loadState } from '@nextcloud/initial-state'
-import { getLanguage, translate as t } from '@nextcloud/l10n'
-import { basename } from 'path'
+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.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'
-// The return type of the initial state
-interface IFavoriteFolder {
- fileid: number
- path: string
-}
-
-export const generateFavoriteFolderView = function(folder: IFavoriteFolder, index = 0): View {
+const generateFavoriteFolderView = function(folder: Folder, index = 0): View {
return new View({
id: generateIdFromPath(folder.path),
- name: basename(folder.path),
+ name: folder.displayname,
icon: FolderSvg,
order: index,
+
params: {
dir: folder.path,
- fileid: folder.fileid.toString(),
+ fileid: String(folder.fileid),
view: 'favorites',
},
@@ -43,21 +39,16 @@ export const generateFavoriteFolderView = function(folder: IFavoriteFolder, inde
})
}
-export const generateIdFromPath = function(path: string): string {
+const generateIdFromPath = function(path: string): string {
return `favorite-${hashCode(path)}`
}
-export default () => {
- // Load state in function for mock testing purposes
- const favoriteFolders = loadState<IFavoriteFolder[]>('files', 'favoriteFolders', [])
- const favoriteFoldersViews = favoriteFolders.map((folder, index) => generateFavoriteFolderView(folder, index)) as View[]
- logger.debug('Generating favorites view', { favoriteFolders })
-
+export const registerFavoritesView = async () => {
const Navigation = getNavigation()
Navigation.register(new View({
id: 'favorites',
name: t('files', 'Favorites'),
- caption: t('files', 'List of favorites files and folders.'),
+ 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'),
@@ -70,10 +61,13 @@ export default () => {
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 favourites navigation when a new folder is added
+ * Update favorites navigation when a new folder is added
*/
subscribe('files:favorites:added', (node: Node) => {
if (node.type !== FileType.Folder) {
@@ -90,7 +84,7 @@ export default () => {
})
/**
- * Remove favourites navigation when a folder is removed
+ * Remove favorites navigation when a folder is removed
*/
subscribe('files:favorites:removed', (node: Node) => {
if (node.type !== FileType.Folder) {
@@ -107,11 +101,26 @@ export default () => {
})
/**
+ * 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.path.localeCompare(b.path, getLanguage(), { ignorePunctuation: true }))
+ favoriteFolders.sort((a, b) => a.basename.localeCompare(b.basename, [getLanguage(), getCanonicalLocale()], { ignorePunctuation: true, numeric: true, usage: 'sort' }))
favoriteFolders.forEach((folder, index) => {
const view = favoriteFoldersViews.find((view) => view.id === generateIdFromPath(folder.path))
if (view) {
@@ -122,8 +131,7 @@ export default () => {
// Add a folder to the favorites paths array and update the views
const addToFavorites = function(node: Folder) {
- const newFavoriteFolder: IFavoriteFolder = { path: node.path, fileid: node.fileid! }
- const view = generateFavoriteFolderView(newFavoriteFolder)
+ const view = generateFavoriteFolderView(node)
// Skip if already exists
if (favoriteFolders.find((folder) => folder.path === node.path)) {
@@ -131,7 +139,7 @@ export default () => {
}
// Update arrays
- favoriteFolders.push(newFavoriteFolder)
+ favoriteFolders.push(node)
favoriteFoldersViews.push(view)
// Update and sort views
@@ -157,4 +165,19 @@ export default () => {
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
index a49a13f91e1..a94aab0f14b 100644
--- a/apps/files/src/views/files.ts
+++ b/apps/files/src/views/files.ts
@@ -2,22 +2,64 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
-import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
-import { getContents } from '../services/Files'
+import { emit, subscribe } from '@nextcloud/event-bus'
import { View, getNavigation } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { getContents } from '../services/Files.ts'
+import { useActiveStore } from '../store/active.ts'
+import { defaultView } from '../utils/filesViews.ts'
+
+import FolderSvg from '@mdi/svg/svg/folder-outline.svg?raw'
+
+export const VIEW_ID = 'files'
+
+/**
+ * Register the files view to the navigation
+ */
+export function registerFilesView() {
+ // we cache the query to allow more performant search (see below in event listener)
+ let oldQuery = ''
-export default () => {
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'files',
+ id: VIEW_ID,
name: t('files', 'All files'),
caption: t('files', 'List of your files and folders.'),
icon: FolderSvg,
- order: 0,
+ // if this is the default view we set it at the top of the list - otherwise below it
+ order: defaultView() === VIEW_ID ? 0 : 5,
getContents,
}))
+
+ // when the search is updated
+ // and we are in the files view
+ // and there is already a folder fetched
+ // then we "update" it to trigger a new `getContents` call to search for the query while the filelist is filtered
+ subscribe('files:search:updated', ({ scope, query }) => {
+ if (scope === 'globally') {
+ return
+ }
+
+ if (Navigation.active?.id !== VIEW_ID) {
+ return
+ }
+
+ // If neither the old query nor the new query is longer than the search minimum
+ // then we do not need to trigger a new PROPFIND / SEARCH
+ // so we skip unneccessary requests here
+ if (oldQuery.length < 3 && query.length < 3) {
+ return
+ }
+
+ const store = useActiveStore()
+ if (!store.activeFolder) {
+ return
+ }
+
+ oldQuery = query
+ emit('files:node:updated', store.activeFolder)
+ })
}
diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts
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
index ce175d7c5ca..241582057d1 100644
--- a/apps/files/src/views/personal-files.ts
+++ b/apps/files/src/views/personal-files.ts
@@ -2,24 +2,36 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
+
+import { t } from '@nextcloud/l10n'
import { View, getNavigation } from '@nextcloud/files'
+import { getContents } from '../services/PersonalFiles.ts'
+import { defaultView, hasPersonalFilesView } from '../utils/filesViews.ts'
+
+import AccountIcon from '@mdi/svg/svg/account-outline.svg?raw'
+
+export const VIEW_ID = 'personal'
-import { getContents } from '../services/PersonalFiles'
-import AccountIcon from '@mdi/svg/svg/account.svg?raw'
+/**
+ * Register the personal files view if allowed
+ */
+export function registerPersonalFilesView(): void {
+ if (!hasPersonalFilesView()) {
+ return
+ }
-export default () => {
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'personal',
- name: t('files', 'Personal Files'),
+ id: VIEW_ID,
+ name: t('files', 'Personal files'),
caption: t('files', 'List of your files and folders that are not shared.'),
emptyTitle: t('files', 'No personal files found'),
emptyCaption: t('files', 'Files that are not shared will show up here.'),
icon: AccountIcon,
- order: 5,
+ // if this is the default view we set it at the top of the list - otherwise default position of fifth
+ order: defaultView() === VIEW_ID ? 0 : 5,
getContents,
}))
diff --git a/apps/files/src/views/search.ts b/apps/files/src/views/search.ts
new file mode 100644
index 00000000000..a30f732163c
--- /dev/null
+++ b/apps/files/src/views/search.ts
@@ -0,0 +1,51 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance'
+
+import { View, getNavigation } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import { getContents } from '../services/Search.ts'
+import { VIEW_ID as FILES_VIEW_ID } from './files.ts'
+import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw'
+import Vue from 'vue'
+
+export const VIEW_ID = 'search'
+
+/**
+ * Register the search-in-files view
+ */
+export function registerSearchView() {
+ let instance: Vue
+ let view: ComponentPublicInstanceConstructor
+
+ const Navigation = getNavigation()
+ Navigation.register(new View({
+ id: VIEW_ID,
+ name: t('files', 'Search'),
+ caption: t('files', 'Search results within your files.'),
+
+ async emptyView(el) {
+ if (!view) {
+ view = (await import('./SearchEmptyView.vue')).default
+ } else {
+ instance.$destroy()
+ }
+ instance = new Vue(view)
+ instance.$mount(el)
+ },
+
+ icon: MagnifySvg,
+ order: 10,
+
+ parent: FILES_VIEW_ID,
+ // it should be shown expanded
+ expanded: true,
+ // this view is hidden by default and only shown when active
+ hidden: true,
+
+ getContents,
+ }))
+}