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/FilesList.vue233
-rw-r--r--apps/files/src/views/Navigation.cy.ts28
-rw-r--r--apps/files/src/views/Navigation.vue16
-rw-r--r--apps/files/src/views/SearchEmptyView.vue53
-rw-r--r--apps/files/src/views/Settings.vue48
-rw-r--r--apps/files/src/views/TemplatePicker.vue8
-rw-r--r--apps/files/src/views/files.ts54
-rw-r--r--apps/files/src/views/personal-files.ts23
-rw-r--r--apps/files/src/views/search.ts51
9 files changed, 387 insertions, 127 deletions
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index 0aa3da144c2..15a7f93ddf0 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -73,93 +73,99 @@
<!-- Drag and drop notice -->
<DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" />
- <!-- Initial loading -->
- <NcLoadingIcon v-if="loading && !isRefreshing"
+ <!--
+ Initial current view loading0. This should never happen,
+ views are supposed to be registered far earlier in the lifecycle.
+ In case the URL is bad or a view is missing, we show a loading icon.
+ -->
+ <NcLoadingIcon v-if="!currentView"
class="files-list__loading-icon"
:size="38"
:name="t('files', 'Loading current folder')" />
- <!-- Empty content placeholder -->
- <template v-else-if="!loading && isEmptyDir && currentFolder && currentView">
- <div class="files-list__before">
- <!-- Headers -->
- <FilesListHeader v-for="header in headers"
- :key="header.id"
- :current-folder="currentFolder"
- :current-view="currentView"
- :header="header" />
- </div>
- <!-- Empty due to error -->
- <NcEmptyContent v-if="error" :name="error" data-cy-files-content-error>
- <template #action>
- <NcButton type="secondary" @click="fetchContent">
- <template #icon>
- <IconReload :size="20" />
- </template>
- {{ t('files', 'Retry') }}
- </NcButton>
- </template>
- <template #icon>
- <IconAlertCircleOutline />
- </template>
- </NcEmptyContent>
- <!-- Custom empty view -->
- <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper">
- <div ref="customEmptyView" />
- </div>
- <!-- Default empty directory view -->
- <NcEmptyContent v-else
- :name="currentView?.emptyTitle || t('files', 'No files in here')"
- :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
- data-cy-files-content-empty>
- <template v-if="directory !== '/'" #action>
- <!-- Uploader -->
- <UploadPicker v-if="canUpload && !isQuotaExceeded"
- allow-folders
- class="files-list__header-upload-button"
- :content="getContent"
- :destination="currentFolder"
- :forbidden-characters="forbiddenCharacters"
- multiple
- @failed="onUploadFail"
- @uploaded="onUpload" />
- <NcButton v-else :to="toPreviousDir" type="primary">
- {{ t('files', 'Go back') }}
- </NcButton>
- </template>
- <template #icon>
- <NcIconSvgWrapper :svg="currentView.icon" />
- </template>
- </NcEmptyContent>
- </template>
-
- <!-- File list -->
+ <!-- File list - always mounted -->
<FilesListVirtual v-else
ref="filesListVirtual"
:current-folder="currentFolder"
:current-view="currentView"
:nodes="dirContentsSorted"
- :summary="summary" />
+ :summary="summary">
+ <template #empty>
+ <!-- Initial loading -->
+ <NcLoadingIcon v-if="loading && !isRefreshing"
+ class="files-list__loading-icon"
+ :size="38"
+ :name="t('files', 'Loading current folder')" />
+
+ <!-- Empty due to error -->
+ <NcEmptyContent v-else-if="error" :name="error" data-cy-files-content-error>
+ <template #action>
+ <NcButton type="secondary" @click="fetchContent">
+ <template #icon>
+ <IconReload :size="20" />
+ </template>
+ {{ t('files', 'Retry') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <IconAlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+
+ <!-- Custom empty view -->
+ <div v-else-if="currentView?.emptyView" class="files-list__empty-view-wrapper">
+ <div ref="customEmptyView" />
+ </div>
+
+ <!-- Default empty directory view -->
+ <NcEmptyContent v-else
+ :name="currentView?.emptyTitle || t('files', 'No files in here')"
+ :description="currentView?.emptyCaption || t('files', 'Upload some content or sync with your devices!')"
+ data-cy-files-content-empty>
+ <template v-if="directory !== '/'" #action>
+ <!-- Uploader -->
+ <UploadPicker v-if="canUpload && !isQuotaExceeded"
+ allow-folders
+ class="files-list__header-upload-button"
+ :content="getContent"
+ :destination="currentFolder"
+ :forbidden-characters="forbiddenCharacters"
+ multiple
+ @failed="onUploadFail"
+ @uploaded="onUpload" />
+ <NcButton v-else :to="toPreviousDir" type="primary">
+ {{ t('files', 'Go back') }}
+ </NcButton>
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :svg="currentView?.icon" />
+ </template>
+ </NcEmptyContent>
+ </template>
+ </FilesListVirtual>
</NcAppContent>
</template>
<script lang="ts">
-import type { ContentsWithRoot, FileListAction, Folder, INode } from '@nextcloud/files'
+import type { ContentsWithRoot, FileListAction, INode } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'
import type { CancelablePromise } from 'cancelable-promise'
import type { ComponentPublicInstance } from 'vue'
import type { Route } from 'vue-router'
import type { UserConfig } from '../types.ts'
+import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files'
+import { Folder, Node, Permission, sortNodes, getFileListActions } from '@nextcloud/files'
+import { getRemoteURL, getRootPath } from '@nextcloud/files/dav'
import { translate as t } from '@nextcloud/l10n'
-import { join, dirname, normalize } from 'path'
+import { join, dirname, normalize, relative } from 'path'
import { showError, showSuccess, showWarning } from '@nextcloud/dialogs'
import { ShareType } from '@nextcloud/sharing'
import { UploadPicker, UploadStatus } from '@nextcloud/upload'
import { loadState } from '@nextcloud/initial-state'
+import { useThrottleFn } from '@vueuse/core'
import { defineComponent } from 'vue'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
@@ -178,22 +184,22 @@ import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue
import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getSummaryFor } from '../utils/fileUtils.ts'
-import { humanizeWebDAVError } from '../utils/davUtils.ts'
-import { useFileListHeaders } from '../composables/useFileListHeaders.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 { useNavigation } from '../composables/useNavigation.ts'
import { usePathsStore } from '../store/paths.ts'
-import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
+import { humanizeWebDAVError } from '../utils/davUtils.ts'
+import { getSummaryFor } from '../utils/fileUtils.ts'
+import { defaultView } from '../utils/filesViews.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import DragAndDropNotice from '../components/DragAndDropNotice.vue'
-import FilesListHeader from '../components/FilesListHeader.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.ts'
@@ -206,7 +212,6 @@ export default defineComponent({
components: {
BreadCrumbs,
DragAndDropNotice,
- FilesListHeader,
FilesListVirtual,
LinkIcon,
ListViewIcon,
@@ -239,6 +244,8 @@ export default defineComponent({
const { currentView } = useNavigation()
const { directory, fileId } = useRouteParameters()
const fileListWidth = useFileListWidth()
+
+ const activeStore = useActiveStore()
const filesStore = useFilesStore()
const filtersStore = useFiltersStore()
const pathsStore = usePathsStore()
@@ -255,9 +262,9 @@ export default defineComponent({
directory,
fileId,
fileListWidth,
- headers: useFileListHeaders(),
t,
+ activeStore,
filesStore,
filtersStore,
pathsStore,
@@ -320,21 +327,23 @@ export default defineComponent({
/**
* The current folder.
*/
- currentFolder(): Folder | undefined {
- if (!this.currentView?.id) {
- return
- }
-
- if (this.directory === '/') {
- return this.filesStore.getRoot(this.currentView.id)
- }
+ currentFolder(): Folder {
+ // Temporary fake folder to use until we have the first valid folder
+ // fetched and cached. This allow us to mount the FilesListVirtual
+ // at all time and avoid unmount/mount and undesired rendering issues.
+ const dummyFolder = new Folder({
+ id: 0,
+ source: getRemoteURL() + getRootPath(),
+ root: getRootPath(),
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.NONE,
+ })
- const source = this.pathsStore.getPath(this.currentView.id, this.directory)
- if (source === undefined) {
- return
+ if (!this.currentView?.id) {
+ return dummyFolder
}
- return this.filesStore.getNode(source) as Folder
+ return this.filesStore.getDirectoryByPath(this.currentView.id, this.directory) || dummyFolder
},
dirContents(): Node[] {
@@ -346,7 +355,7 @@ export default defineComponent({
/**
* The current directory contents.
*/
- dirContentsSorted() {
+ dirContentsSorted(): INode[] {
if (!this.currentView) {
return []
}
@@ -360,12 +369,28 @@ export default defineComponent({
return this.isAscSorting ? results : results.reverse()
}
- return sortNodes(this.dirContentsFiltered, {
+ const nodes = sortNodes(this.dirContentsFiltered, {
sortFavoritesFirst: this.userConfig.sort_favorites_first,
sortFoldersFirst: this.userConfig.sort_folders_first,
sortingMode: this.sortingMode,
sortingOrder: this.isAscSorting ? 'asc' : 'desc',
})
+
+ // TODO upstream this
+ if (this.currentView.id === 'files') {
+ nodes.sort((a, b) => {
+ const aa = relative(a.source, this.currentFolder!.source) === '..'
+ const bb = relative(b.source, this.currentFolder!.source) === '..'
+ if (aa && bb) {
+ return 0
+ } else if (aa) {
+ return -1
+ }
+ return 1
+ })
+ }
+
+ return nodes
},
/**
@@ -479,16 +504,13 @@ export default defineComponent({
const hidden = this.dirContents.length - this.dirContentsFiltered.length
return getSummaryFor(this.dirContentsFiltered, hidden)
},
- },
- watch: {
- /**
- * Update the window title to match the page heading
- */
- pageHeading() {
- document.title = `${this.pageHeading} - ${getCapabilities().theming?.productName ?? 'Nextcloud'}`
+ 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
@@ -503,6 +525,10 @@ export default defineComponent({
}
},
+ currentFolder() {
+ this.activeStore.activeFolder = this.currentFolder
+ },
+
currentView(newView, oldView) {
if (newView?.id === oldView?.id) {
return
@@ -547,14 +573,16 @@ export default defineComponent({
// 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())
+ 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()) {
+ if (!node && this.currentFolder?.fileid?.toString() !== this.fileId.toString()) {
showError(t('files', 'The file could not be found'))
}
}
@@ -564,9 +592,17 @@ export default defineComponent({
unsubscribe('files:node:deleted', this.onNodeDeleted)
unsubscribe('files:node:updated', this.onUpdatedNode)
unsubscribe('files:config:updated', this.fetchContent)
+ unsubscribe('files:filters:changed', this.filterDirContent)
+ unsubscribe('files:search:updated', this.onUpdateSearch)
},
methods: {
+ onUpdateSearch({ query, scope }) {
+ if (query && scope !== 'filter') {
+ this.debouncedFetchContent()
+ }
+ },
+
async fetchContent() {
this.loading = true
this.error = null
@@ -574,10 +610,21 @@ export default defineComponent({
const currentView = this.currentView
if (!currentView) {
- logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
+ logger.debug('The current view does not exists or is not ready.', { currentView })
+
+ // If we still haven't a valid view, let's wait for the page to load
+ // then try again. Else redirect to the default view
+ window.addEventListener('DOMContentLoaded', () => {
+ if (!this.currentView) {
+ logger.warn('No current view after DOMContentLoaded, redirecting to the default view')
+ window.OCP.Files.Router.goToRoute(null, { view: defaultView() })
+ }
+ }, { once: true })
return
}
+ logger.debug('Fetching contents for directory', { dir, currentView })
+
// If we have a cancellable promise ongoing, cancel it
if (this.promise && 'cancel' in this.promise) {
this.promise.cancel()
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index a88878e2d3a..7357943ee28 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -10,7 +10,8 @@ import NavigationView from './Navigation.vue'
import { useViewConfigStore } from '../store/viewConfig'
import { Folder, View, getNavigation } from '@nextcloud/files'
-import router from '../router/router'
+import router from '../router/router.ts'
+import RouterService from '../services/RouterService'
const resetNavigation = () => {
const nav = getNavigation()
@@ -27,9 +28,18 @@ const createView = (id: string, name: string, parent?: string) => new View({
parent,
})
+function mockWindow() {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router = new RouterService(router)
+}
+
describe('Navigation renders', () => {
- before(() => {
+ before(async () => {
delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
cy.mockInitialState('files', 'storageStats', {
used: 1000 * 1000 * 1000,
@@ -41,6 +51,7 @@ describe('Navigation renders', () => {
it('renders', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -60,6 +71,7 @@ describe('Navigation API', () => {
before(async () => {
delete window._nc_navigation
Navigation = getNavigation()
+ mockWindow()
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
@@ -152,14 +164,18 @@ describe('Navigation API', () => {
})
describe('Quota rendering', () => {
- before(() => {
+ before(async () => {
delete window._nc_navigation
+ mockWindow()
+ getNavigation().register(createView('files', 'Files'))
+ await router.replace({ name: 'filelist', params: { view: 'files' } })
})
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -174,9 +190,11 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: -1,
+ total: 50 * 1024 * 1024 * 1024,
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -193,10 +211,12 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: 5 * 1024 * 1024 * 1024,
+ total: 5 * 1024 * 1024 * 1024,
relative: 20, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -215,10 +235,12 @@ describe('Quota rendering', () => {
cy.mockInitialState('files', 'storageStats', {
used: 5 * 1024 * 1024 * 1024,
quota: 1024 * 1024 * 1024,
+ total: 1024 * 1024 * 1024,
relative: 500, // percent
})
cy.mount(NavigationView, {
+ router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue
index 3147268f34d..c424a0d74b8 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -7,7 +7,7 @@
class="files-navigation"
:aria-label="t('files', 'Files')">
<template #search>
- <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter file names …')" />
+ <FilesNavigationSearch />
</template>
<template #default>
<NcAppNavigationList class="files-navigation__list"
@@ -39,24 +39,24 @@
</template>
<script lang="ts">
-import { getNavigation, type View } from '@nextcloud/files'
+import type { View } from '@nextcloud/files'
import type { ViewConfig } from '../types.ts'
-import { defineComponent } from 'vue'
import { emit, subscribe } from '@nextcloud/event-bus'
-import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+import { getNavigation } from '@nextcloud/files'
+import { t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
import IconCog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
-import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
import FilesNavigationItem from '../components/FilesNavigationItem.vue'
+import FilesNavigationSearch from '../components/FilesNavigationSearch.vue'
import { useNavigation } from '../composables/useNavigation'
-import { useFilenameFilter } from '../composables/useFilenameFilter'
import { useFiltersStore } from '../store/filters.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.ts'
@@ -75,12 +75,12 @@ export default defineComponent({
components: {
IconCog,
FilesNavigationItem,
+ FilesNavigationSearch,
NavigationQuota,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationList,
- NcAppNavigationSearch,
SettingsModal,
},
@@ -88,11 +88,9 @@ export default defineComponent({
const filtersStore = useFiltersStore()
const viewConfigStore = useViewConfigStore()
const { currentView, views } = useNavigation()
- const { searchQuery } = useFilenameFilter()
return {
currentView,
- searchQuery,
t,
views,
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 a9967fdbef9..49a348eabc3 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -9,6 +9,27 @@
@update:open="onClose">
<!-- Settings API-->
<NcAppSettingsSection id="settings" :name="t('files', 'Files settings')">
+ <fieldset class="files-settings__default-view"
+ data-cy-files-settings-setting="default_view">
+ <legend>
+ {{ t('files', 'Default view') }}
+ </legend>
+ <NcCheckboxRadioSwitch :model-value="userConfig.default_view"
+ name="default_view"
+ type="radio"
+ value="files"
+ @update:model-value="setConfig('default_view', $event)">
+ {{ t('files', 'All files') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :model-value="userConfig.default_view"
+ name="default_view"
+ type="radio"
+ value="personal"
+ @update:model-value="setConfig('default_view', $event)">
+ {{ t('files', 'Personal files') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+
<NcCheckboxRadioSwitch data-cy-files-settings-setting="sort_favorites_first"
:checked="userConfig.sort_favorites_first"
@update:checked="setConfig('sort_favorites_first', $event)">
@@ -34,12 +55,6 @@
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="enableGridView"
- data-cy-files-settings-setting="grid_view"
- :checked="userConfig.grid_view"
- @update:checked="setConfig('grid_view', $event)">
- {{ t('files', 'Enable the grid view') }}
- </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree"
:checked="userConfig.folder_tree"
@update:checked="setConfig('folder_tree', $event)">
@@ -95,6 +110,11 @@
@update:checked="setConfig('show_dialog_file_extension', $event)">
{{ t('files', 'Show a warning dialog when changing a file extension.') }}
</NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="userConfig.show_dialog_deletion"
+ @update:checked="setConfig('show_dialog_deletion', $event)">
+ {{ t('files', 'Show a warning dialog when deleting files.') }}
+ </NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<NcAppSettingsSection id="shortcuts"
@@ -320,6 +340,16 @@ export default {
userConfig() {
return this.userConfigStore.userConfig
},
+
+ sortedSettings() {
+ // Sort settings by name
+ return [...this.settings].sort((a, b) => {
+ if (a.order && b.order) {
+ return a.order - b.order
+ }
+ return a.name.localeCompare(b.name)
+ })
+ },
},
created() {
@@ -380,6 +410,12 @@ export default {
</script>
<style lang="scss" scoped>
+.files-settings {
+ &__default-view {
+ margin-bottom: 0.5rem;
+ }
+}
+
.setting-link:hover {
text-decoration: underline;
}
diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue
index 2682570bd5d..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -275,7 +275,13 @@ export default defineComponent({
async onSubmit() {
const fileId = this.selectedTemplate?.fileid
- const fields = await getTemplateFields(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, {
diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts
index a49a13f91e1..95450f0d71a 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.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/personal-files.ts b/apps/files/src/views/personal-files.ts
index 66d4e77b376..36888eb7ee0 100644
--- a/apps/files/src/views/personal-files.ts
+++ b/apps/files/src/views/personal-files.ts
@@ -2,23 +2,27 @@
* 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 { getContents } from '../services/PersonalFiles'
import AccountIcon from '@mdi/svg/svg/account.svg?raw'
-import { loadState } from '@nextcloud/initial-state'
-export default () => {
- // Don't show this view if the user has no storage quota
- const storageStats = loadState('files', 'storageStats', { quota: -1 })
- if (storageStats.quota === 0) {
+export const VIEW_ID = 'personal'
+
+/**
+ * Register the personal files view if allowed
+ */
+export function registerPersonalFilesView(): void {
+ if (!hasPersonalFilesView()) {
return
}
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'personal',
+ id: VIEW_ID,
name: t('files', 'Personal files'),
caption: t('files', 'List of your files and folders that are not shared.'),
@@ -26,7 +30,8 @@ export default () => {
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,
+ }))
+}