aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/actions/openInFilesAction.spec.ts2
-rw-r--r--apps/files/src/actions/openInFilesAction.ts18
-rw-r--r--apps/files/src/actions/openLocallyAction.ts139
-rw-r--r--apps/files/src/actions/renameAction.spec.ts22
-rw-r--r--apps/files/src/actions/renameAction.ts18
-rw-r--r--apps/files/src/components/FileEntry.vue46
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue2
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue12
-rw-r--r--apps/files/src/components/FileEntryMixin.ts6
-rw-r--r--apps/files/src/components/FileListFilter/FileListFilterToSearch.vue47
-rw-r--r--apps/files/src/components/FilesListHeader.vue52
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue8
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue12
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue5
-rw-r--r--apps/files/src/components/FilesListVirtual.vue146
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue8
-rw-r--r--apps/files/src/components/FilesNavigationSearch.vue86
-rw-r--r--apps/files/src/components/NavigationQuota.vue2
-rw-r--r--apps/files/src/components/VirtualList.vue15
-rw-r--r--apps/files/src/composables/useBeforeNavigation.ts20
-rw-r--r--apps/files/src/composables/useFilenameFilter.ts47
-rw-r--r--apps/files/src/eventbus.d.ts12
-rw-r--r--apps/files/src/filters/FilenameFilter.ts28
-rw-r--r--apps/files/src/filters/SearchFilter.ts49
-rw-r--r--apps/files/src/init.ts13
-rw-r--r--apps/files/src/router/router.ts78
-rw-r--r--apps/files/src/services/FileInfo.ts1
-rw-r--r--apps/files/src/services/Files.ts66
-rw-r--r--apps/files/src/services/HotKeysService.spec.ts74
-rw-r--r--apps/files/src/services/Search.spec.ts61
-rw-r--r--apps/files/src/services/Search.ts43
-rw-r--r--apps/files/src/services/Templates.js5
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
-rw-r--r--apps/files/src/store/active.ts112
-rw-r--r--apps/files/src/store/files.ts25
-rw-r--r--apps/files/src/store/renaming.ts10
-rw-r--r--apps/files/src/store/search.ts153
-rw-r--r--apps/files/src/store/userconfig.ts6
-rw-r--r--apps/files/src/types.ts18
-rw-r--r--apps/files/src/utils/actionUtils.ts4
-rw-r--r--apps/files/src/utils/fileUtils.ts44
-rw-r--r--apps/files/src/utils/filesViews.spec.ts75
-rw-r--r--apps/files/src/utils/filesViews.ts30
-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.vue38
-rw-r--r--apps/files/src/views/TemplatePicker.vue17
-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
52 files changed, 1757 insertions, 459 deletions
diff --git a/apps/files/src/actions/openInFilesAction.spec.ts b/apps/files/src/actions/openInFilesAction.spec.ts
index e732270d4c0..3ccd15fa2d2 100644
--- a/apps/files/src/actions/openInFilesAction.spec.ts
+++ b/apps/files/src/actions/openInFilesAction.spec.ts
@@ -19,7 +19,7 @@ const recentView = {
describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
- expect(action.id).toBe('open-in-files-recent')
+ expect(action.id).toBe('open-in-files')
expect(action.displayName([], recentView)).toBe('Open in Files')
expect(action.iconSvgInline([], recentView)).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts
index 10e19e7eace..9e10b1ac74e 100644
--- a/apps/files/src/actions/openInFilesAction.ts
+++ b/apps/files/src/actions/openInFilesAction.ts
@@ -2,19 +2,21 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { translate as t } from '@nextcloud/l10n'
-import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files'
-/**
- * TODO: Move away from a redirect and handle
- * navigation straight out of the recent view
- */
+import type { Node } from '@nextcloud/files'
+
+import { t } from '@nextcloud/l10n'
+import { FileType, FileAction, DefaultType } from '@nextcloud/files'
+import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search'
+
export const action = new FileAction({
- id: 'open-in-files-recent',
+ id: 'open-in-files',
displayName: () => t('files', 'Open in Files'),
iconSvgInline: () => '',
- enabled: (nodes, view) => view.id === 'recent',
+ enabled(nodes, view) {
+ return view.id === 'recent' || view.id === SEARCH_VIEW_ID
+ },
async exec(node: Node) {
let dir = node.dirname
diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts
index a80cf0cbeed..986b304210c 100644
--- a/apps/files/src/actions/openLocallyAction.ts
+++ b/apps/files/src/actions/openLocallyAction.ts
@@ -13,71 +13,6 @@ import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw'
import IconWeb from '@mdi/svg/svg/web.svg?raw'
import { isPublicShare } from '@nextcloud/sharing/public'
-const confirmLocalEditDialog = (
- localEditCallback: (openingLocally: boolean) => void = () => {},
-) => {
- let callbackCalled = false
-
- return (new DialogBuilder())
- .setName(t('files', 'Open file locally'))
- .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.'))
- .setButtons([
- {
- label: t('files', 'Retry and close'),
- type: 'secondary',
- callback: () => {
- callbackCalled = true
- localEditCallback(true)
- },
- },
- {
- label: t('files', 'Open online'),
- icon: IconWeb,
- type: 'primary',
- callback: () => {
- callbackCalled = true
- localEditCallback(false)
- },
- },
- ])
- .build()
- .show()
- .then(() => {
- // Ensure the callback is called even if the dialog is dismissed in other ways
- if (!callbackCalled) {
- localEditCallback(false)
- }
- })
-}
-
-const attemptOpenLocalClient = async (path: string) => {
- openLocalClient(path)
- confirmLocalEditDialog(
- (openLocally: boolean) => {
- if (!openLocally) {
- window.OCA.Viewer.open({ path })
- return
- }
- openLocalClient(path)
- },
- )
-}
-
-const openLocalClient = async function(path: string) {
- const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json'
-
- try {
- const result = await axios.post(link, { path })
- const uid = getCurrentUser()?.uid
- let url = `nc://open/${uid}@` + window.location.host + encodePath(path)
- url += '?token=' + result.data.ocs.data.token
-
- window.open(url, '_self')
- } catch (error) {
- showError(t('files', 'Failed to redirect to client'))
- }
-}
-
export const action = new FileAction({
id: 'edit-locally',
displayName: () => t('files', 'Open locally'),
@@ -99,9 +34,81 @@ export const action = new FileAction({
},
async exec(node: Node) {
- attemptOpenLocalClient(node.path)
+ await attemptOpenLocalClient(node.path)
return null
},
order: 25,
})
+
+/**
+ * Try to open the path in the Nextcloud client.
+ *
+ * If this fails a dialog is shown with 3 options:
+ * 1. Retry: If it fails no further dialog is shown.
+ * 2. Open online: The viewer is used to open the file.
+ * 3. Close the dialog and nothing happens (abort).
+ *
+ * @param path - The path to open
+ */
+async function attemptOpenLocalClient(path: string) {
+ await openLocalClient(path)
+ const result = await confirmLocalEditDialog()
+ if (result === 'local') {
+ await openLocalClient(path)
+ } else if (result === 'online') {
+ window.OCA.Viewer.open({ path })
+ }
+}
+
+/**
+ * Try to open a file in the Nextcloud client.
+ * There is no way to get notified if this action was successfull.
+ *
+ * @param path - Path to open
+ */
+async function openLocalClient(path: string): Promise<void> {
+ const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json'
+
+ try {
+ const result = await axios.post(link, { path })
+ const uid = getCurrentUser()?.uid
+ let url = `nc://open/${uid}@` + window.location.host + encodePath(path)
+ url += '?token=' + result.data.ocs.data.token
+
+ window.open(url, '_self')
+ } catch (error) {
+ showError(t('files', 'Failed to redirect to client'))
+ }
+}
+
+/**
+ * Open the confirmation dialog.
+ */
+async function confirmLocalEditDialog(): Promise<'online'|'local'|false> {
+ let result: 'online'|'local'|false = false
+ const dialog = (new DialogBuilder())
+ .setName(t('files', 'Open file locally'))
+ .setText(t('files', 'The file should now open on your device. If it doesn\'t, please check that you have the desktop app installed.'))
+ .setButtons([
+ {
+ label: t('files', 'Retry and close'),
+ type: 'secondary',
+ callback: () => {
+ result = 'local'
+ },
+ },
+ {
+ label: t('files', 'Open online'),
+ icon: IconWeb,
+ type: 'primary',
+ callback: () => {
+ result = 'online'
+ },
+ },
+ ])
+ .build()
+
+ await dialog.show()
+ return result
+}
diff --git a/apps/files/src/actions/renameAction.spec.ts b/apps/files/src/actions/renameAction.spec.ts
index 954eca5820f..1f9c9209d41 100644
--- a/apps/files/src/actions/renameAction.spec.ts
+++ b/apps/files/src/actions/renameAction.spec.ts
@@ -3,15 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { action } from './renameAction'
-import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
import * as eventBus from '@nextcloud/event-bus'
-import { describe, expect, test, vi } from 'vitest'
+import { describe, expect, test, vi, beforeEach } from 'vitest'
+import { useFilesStore } from '../store/files'
+import { getPinia } from '../store/index.ts'
const view = {
id: 'files',
name: 'Files',
} as View
+beforeEach(() => {
+ const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
+ const files = useFilesStore(getPinia())
+ files.setRoot({ service: 'files', root })
+})
+
describe('Rename action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
@@ -26,7 +34,7 @@ describe('Rename action conditions tests', () => {
describe('Rename action enabled tests', () => {
test('Enabled for node with UPDATE permission', () => {
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
@@ -39,7 +47,7 @@ describe('Rename action enabled tests', () => {
test('Disabled for node without DELETE permission', () => {
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
@@ -54,13 +62,13 @@ describe('Rename action enabled tests', () => {
window.OCA = { Files: { Sidebar: {} } }
const file1 = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
owner: 'admin',
mime: 'text/plain',
})
const file2 = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
owner: 'admin',
mime: 'text/plain',
@@ -76,7 +84,7 @@ describe('Rename action exec tests', () => {
vi.spyOn(eventBus, 'emit')
const file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
diff --git a/apps/files/src/actions/renameAction.ts b/apps/files/src/actions/renameAction.ts
index e0ea784c291..d421d18c473 100644
--- a/apps/files/src/actions/renameAction.ts
+++ b/apps/files/src/actions/renameAction.ts
@@ -6,6 +6,9 @@ import { emit } from '@nextcloud/event-bus'
import { Permission, type Node, FileAction, View } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import PencilSvg from '@mdi/svg/svg/pencil.svg?raw'
+import { getPinia } from '../store'
+import { useFilesStore } from '../store/files'
+import { dirname } from 'path'
export const ACTION_RENAME = 'rename'
@@ -18,12 +21,23 @@ export const action = new FileAction({
if (nodes.length === 0) {
return false
}
+
// Disable for single file shares
if (view.id === 'public-file-share') {
return false
}
- // Only enable if all nodes have the delete permission
- return nodes.every((node) => Boolean(node.permissions & Permission.DELETE))
+
+ const node = nodes[0]
+ const filesStore = useFilesStore(getPinia())
+ const parentNode = node.dirname === '/'
+ ? filesStore.getRoot(view.id)
+ : filesStore.getNode(dirname(node.source))
+ const parentPermissions = parentNode?.permissions || Permission.NONE
+
+ // Only enable if the node have the delete permission
+ // and if the parent folder allows creating files
+ return Boolean(node.permissions & Permission.DELETE)
+ && Boolean(parentPermissions & Permission.CREATE)
},
async exec(node: Node) {
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 9642c4709d8..d66c3fa0ed7 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -49,6 +49,15 @@
:opened.sync="openedMenu"
:source="source" />
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ :title="mime"
+ class="files-list__row-mime"
+ data-cy-files-list-row-mime
+ @click="openDetailsIfAvailable">
+ <span>{{ mime }}</span>
+ </td>
+
<!-- Size -->
<td v-if="!compact && isSizeAvailable"
:style="sizeOpacity"
@@ -85,9 +94,10 @@
</template>
<script lang="ts">
-import { formatFileSize } from '@nextcloud/files'
+import { FileType, formatFileSize } from '@nextcloud/files'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { useNavigation } from '../composables/useNavigation.ts'
@@ -123,6 +133,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isSizeAvailable: {
type: Boolean,
default: false,
@@ -186,6 +200,36 @@ export default defineComponent({
return this.currentView.columns || []
},
+ mime() {
+ if (this.source.type === FileType.Folder) {
+ return this.t('files', 'Folder')
+ }
+
+ if (!this.source.mime || this.source.mime === 'application/octet-stream') {
+ return t('files', 'Unknown file type')
+ }
+
+ if (window.OC?.MimeTypeList?.names?.[this.source.mime]) {
+ return window.OC.MimeTypeList.names[this.source.mime]
+ }
+
+ const baseType = this.source.mime.split('/')[0]
+ const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || ''
+ if (baseType === 'image') {
+ return t('files', '{ext} image', { ext })
+ }
+ if (baseType === 'video') {
+ return t('files', '{ext} video', { ext })
+ }
+ if (baseType === 'audio') {
+ return t('files', '{ext} audio', { ext })
+ }
+ if (baseType === 'text') {
+ return t('files', '{ext} text', { ext })
+ }
+
+ return this.source.mime
+ },
size() {
const size = this.source.size
if (size === undefined || isNaN(size) || size < 0) {
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index c130ab49c0a..ec111a1235d 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -281,7 +281,7 @@ export default defineComponent({
}
// Make sure we set the node as active
- this.activeStore.setActiveNode(this.source)
+ this.activeStore.activeNode = this.source
// Execute the action
await executeAction(action)
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index 2d5844f851f..506677b49af 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -21,6 +21,7 @@
class="files-list__row-icon-blurhash"
aria-hidden="true" />
<img v-if="backgroundFailed !== true"
+ :key="source.fileid"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
@@ -147,6 +148,17 @@ export default defineComponent({
return null
}
+ if (this.source.attributes['has-preview'] !== true
+ && this.source.mime !== undefined
+ && this.source.mime !== 'application/octet-stream'
+ ) {
+ const previewUrl = generateUrl('/core/mimeicon?mime={mime}', {
+ mime: this.source.mime,
+ })
+ const url = new URL(window.location.origin + previewUrl)
+ return url.href
+ }
+
try {
const previewUrl = this.source.attributes.previewUrl
|| (this.isPublic
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index 589073e7b9a..735490c45b3 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -356,7 +356,7 @@ export default defineComponent({
// if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab
// also if there is no default action use this as a fallback
- const metaKeyPressed = event.ctrlKey || event.metaKey || Boolean(event.button & 4)
+ const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1
if (metaKeyPressed || !this.defaultFileAction) {
// If no download permission, then we can not allow to download (direct link) the files
if (isPublicShare() && !isDownloadable(this.source)) {
@@ -368,7 +368,9 @@ export default defineComponent({
: generateUrl('/f/{fileId}', { fileId: this.fileid })
event.preventDefault()
event.stopPropagation()
- window.open(url, metaKeyPressed ? '_self' : undefined)
+
+ // Open the file in a new tab if the meta key or the middle mouse button is clicked
+ window.open(url, metaKeyPressed ? '_blank' : '_self')
return
}
diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
new file mode 100644
index 00000000000..938be171f6d
--- /dev/null
+++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue
@@ -0,0 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcButton v-show="isVisible" @click="onClick">
+ {{ t('files', 'Search everywhere') }}
+ </NcButton>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import { getPinia } from '../../store/index.ts'
+import { useSearchStore } from '../../store/search.ts'
+
+const isVisible = ref(false)
+
+defineExpose({
+ hideButton,
+ showButton,
+})
+
+/**
+ * Hide the button - called by the filter class
+ */
+function hideButton() {
+ isVisible.value = false
+}
+
+/**
+ * Show the button - called by the filter class
+ */
+function showButton() {
+ isVisible.value = true
+}
+
+/**
+ * Button click handler to make the filtering a global search.
+ */
+function onClick() {
+ const searchStore = useSearchStore(getPinia())
+ searchStore.scope = 'globally'
+}
+</script>
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
index cc8dafe344e..31458398028 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -12,6 +12,10 @@
import type { Folder, Header, View } from '@nextcloud/files'
import type { PropType } from 'vue'
+import PQueue from 'p-queue'
+
+import logger from '../logger.ts'
+
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
@@ -34,6 +38,14 @@ export default {
required: true,
},
},
+ setup() {
+ // Create a queue to ensure that the header is only rendered once at a time
+ const queue = new PQueue({ concurrency: 1 })
+
+ return {
+ queue,
+ }
+ },
computed: {
enabled() {
return this.header.enabled?.(this.currentFolder, this.currentView) ?? true
@@ -44,15 +56,45 @@ export default {
if (!enabled) {
return
}
- this.header.updated(this.currentFolder, this.currentView)
+ // If the header is enabled, we need to render it
+ logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header })
+ this.queueUpdate(this.currentFolder, this.currentView)
+ },
+ currentFolder(folder: Folder) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queueUpdate(folder, this.currentView)
},
- currentFolder() {
- this.header.updated(this.currentFolder, this.currentView)
+ currentView(view: View) {
+ this.queueUpdate(this.currentFolder, view)
},
},
+
mounted() {
- console.debug('Mounted', this.header.id)
- this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView)
+ logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header })
+ const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView)
+ this.queue.add(initialRender).then(() => {
+ logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header })
+ }).catch((error) => {
+ logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
+ destroyed() {
+ logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header })
+ },
+
+ methods: {
+ queueUpdate(currentFolder: Folder, currentView: View) {
+ // This method can be used to queue an update of the header
+ // It will ensure that the header is only updated once at a time
+ this.queue.add(() => this.header.updated(currentFolder, currentView))
+ .then(() => {
+ logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header })
+ })
+ .catch((error) => {
+ logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error })
+ })
+ },
},
}
</script>
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
index 63d692c100d..9e8cdc159ee 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -21,6 +21,10 @@
<!-- Actions -->
<td class="files-list__row-actions" />
+ <!-- Mime -->
+ <td v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime" />
+
<!-- Size -->
<td v-if="isSizeAvailable"
class="files-list__column files-list__row-size">
@@ -60,6 +64,10 @@ export default defineComponent({
type: View,
required: true,
},
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
index 341d5e1347d..23e631199eb 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -24,6 +24,14 @@
<!-- Actions -->
<th class="files-list__row-actions" />
+ <!-- Mime -->
+ <th v-if="isMimeAvailable"
+ class="files-list__column files-list__row-mime"
+ :class="{ 'files-list__column--sortable': isMimeAvailable }"
+ :aria-sort="ariaSortForMode('mime')">
+ <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" />
+ </th>
+
<!-- Size -->
<th v-if="isSizeAvailable"
class="files-list__column files-list__row-size"
@@ -83,6 +91,10 @@ export default defineComponent({
],
props: {
+ isMimeAvailable: {
+ type: Boolean,
+ default: false,
+ },
isMtimeAvailable: {
type: Boolean,
default: false,
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index 4d2f2f361e6..53b7e7ef21b 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -6,6 +6,7 @@
<div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions>
<NcActions ref="actionsMenu"
container="#app-content-vue"
+ :boundaries-element="boundariesElement"
:disabled="!!loading || areSomeNodesLoading"
:force-name="true"
:inline="enabledInlineActions.length"
@@ -123,6 +124,8 @@ export default defineComponent({
const fileListWidth = useFileListWidth()
const { directory } = useRouteParameters()
+ const boundariesElement = document.getElementById('app-content-vue')
+
return {
directory,
fileListWidth,
@@ -130,6 +133,8 @@ export default defineComponent({
actionsMenuStore,
filesStore,
selectionStore,
+
+ boundariesElement,
}
},
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 93f567f25a4..04acbd302f5 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -9,6 +9,7 @@
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
+ isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
@@ -20,7 +21,9 @@
</template>
<template v-if="!isNoneSelected" #header-overlay>
- <span class="files-list__selected">{{ t('files', '{count} selected', { count: selectedNodes.length }) }}</span>
+ <span class="files-list__selected">
+ {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
+ </span>
<FilesListTableHeaderActions :current-view="currentView"
:selected-nodes="selectedNodes" />
</template>
@@ -39,15 +42,22 @@
<!-- Table header and sort buttons -->
<FilesListTableHeader ref="thead"
:files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter :current-view="currentView"
:files-list-width="fileListWidth"
+ :is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
@@ -60,12 +70,11 @@
import type { UserConfig } from '../types'
import type { Node as NcNode } from '@nextcloud/files'
import type { ComponentPublicInstance, PropType } from 'vue'
-import type { Location } from 'vue-router'
import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { translate as t } from '@nextcloud/l10n'
+import { n, t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { defineComponent } from 'vue'
@@ -76,6 +85,7 @@ import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useSelectionStore } from '../store/selection.js'
import { useUserConfigStore } from '../store/userconfig.ts'
+import logger from '../logger.ts'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
@@ -85,7 +95,6 @@ import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import VirtualList from './VirtualList.vue'
-import logger from '../logger.ts'
export default defineComponent({
name: 'FilesListVirtual',
@@ -137,6 +146,7 @@ export default defineComponent({
selectionStore,
userConfigStore,
+ n,
t,
}
},
@@ -146,7 +156,6 @@ export default defineComponent({
FileEntry,
FileEntryGrid,
scrollToIndex: 0,
- openFileId: null as number|null,
}
},
@@ -155,6 +164,16 @@ export default defineComponent({
return this.userConfigStore.userConfig
},
+ isMimeAvailable() {
+ if (!this.userConfig.show_mime_column) {
+ return false
+ }
+ // Hide mime column on narrow screens
+ if (this.fileListWidth < 1024) {
+ return false
+ }
+ return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream')
+ },
isMtimeAvailable() {
// Hide mtime column on narrow screens
if (this.fileListWidth < 768) {
@@ -201,39 +220,26 @@ export default defineComponent({
isNoneSelected() {
return this.selectedNodes.length === 0
},
+
+ isEmpty() {
+ return this.nodes.length === 0
+ },
},
watch: {
- fileId: {
- handler(fileId) {
- this.scrollToFile(fileId, false)
- },
- immediate: true,
+ // If nodes gets populated and we have a fileId,
+ // an openFile or openDetails, we fire the appropriate actions.
+ isEmpty() {
+ this.handleOpenQueries()
},
-
- openFile: {
- handler(openFile) {
- if (!openFile || !this.fileId) {
- return
- }
-
- this.handleOpenFile(this.fileId)
- },
- immediate: true,
+ fileId() {
+ this.handleOpenQueries()
},
-
- openDetails: {
- handler(openDetails) {
- // wait for scrolling and updating the actions to settle
- this.$nextTick(() => {
- if (!openDetails || !this.fileId) {
- return
- }
-
- this.openSidebarForFile(this.fileId)
- })
- },
- immediate: true,
+ openFile() {
+ this.handleOpenQueries()
+ },
+ openDetails() {
+ this.handleOpenQueries()
},
},
@@ -263,6 +269,33 @@ export default defineComponent({
},
methods: {
+ handleOpenQueries() {
+ // If the list is empty, or we don't have a fileId,
+ // there's nothing to be done.
+ if (this.isEmpty || !this.fileId) {
+ return
+ }
+
+ logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
+ nodes: this.nodes,
+ fileId: this.fileId,
+ openFile: this.openFile,
+ openDetails: this.openDetails,
+ })
+
+ if (this.openFile) {
+ this.handleOpenFile(this.fileId)
+ }
+
+ if (this.openDetails) {
+ this.openSidebarForFile(this.fileId)
+ }
+
+ if (this.fileId) {
+ this.scrollToFile(this.fileId, false)
+ }
+ },
+
openSidebarForFile(fileId) {
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
@@ -272,7 +305,7 @@ export default defineComponent({
sidebarAction.exec(node, this.currentView, this.currentFolder.path)
return
}
- logger.error(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
+ logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
},
scrollToFile(fileId: number|null, warn = true) {
@@ -288,6 +321,7 @@ export default defineComponent({
}
this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
@@ -299,7 +333,7 @@ export default defineComponent({
delete query.openfile
delete query.opendetails
- this.activeStore.clearActiveNode()
+ this.activeStore.activeNode = undefined
window.OCP.Files.Router.goToRoute(
null,
{ ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') },
@@ -352,15 +386,13 @@ export default defineComponent({
}
// The file is either a folder or has no default action other than downloading
// in this case we need to open the details instead and remove the route from the history
- const query = this.$route.query
- delete query.openfile
- query.opendetails = ''
-
logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
- await this.$router.replace({
- ...(this.$route as Location),
- query,
- })
+ window.OCP.Files.Router.goToRoute(
+ null,
+ this.$route.params,
+ { ...this.$route.query, openfile: undefined, opendetails: '' },
+ true, // silent update of the URL
+ )
},
onDragOver(event: DragEvent) {
@@ -433,7 +465,7 @@ export default defineComponent({
delete query.openfile
delete query.opendetails
- this.activeStore.setActiveNode(node)
+ this.activeStore.activeNode = node
// Silent update of the URL
window.OCP.Files.Router.goToRoute(
@@ -458,6 +490,8 @@ export default defineComponent({
--icon-preview-size: 32px;
--fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
@@ -505,6 +539,13 @@ export default defineComponent({
// Hide the table header below the overlay
margin-block-start: calc(-1 * var(--row-height));
}
+
+ // Visually hide the table when there are no files
+ &--hidden {
+ visibility: hidden;
+ z-index: -1;
+ opacity: 0;
+ }
}
.files-list__filters {
@@ -536,6 +577,7 @@ export default defineComponent({
background-color: var(--color-main-background);
border-block-end: 1px solid var(--color-border);
height: var(--row-height);
+ flex: 0 0 var(--row-height);
}
.files-list__thead,
@@ -554,6 +596,16 @@ export default defineComponent({
top: var(--fixed-block-start-position);
}
+ // Empty content
+ .files-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
tr {
position: relative;
display: flex;
@@ -829,10 +881,12 @@ export default defineComponent({
margin-inline-end: 7px;
}
+ .files-list__row-mime,
.files-list__row-mtime,
.files-list__row-size {
color: var(--color-text-maxcontrast);
}
+
.files-list__row-size {
width: calc(var(--row-height) * 1.5);
// Right align content/text
@@ -843,6 +897,10 @@ export default defineComponent({
width: calc(var(--row-height) * 2);
}
+ .files-list__row-mime {
+ width: calc(var(--row-height) * 2.5);
+ }
+
.files-list__row-column-custom {
width: calc(var(--row-height) * 2);
}
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
index 372a83e1441..c29bc00c67f 100644
--- a/apps/files/src/components/FilesNavigationItem.vue
+++ b/apps/files/src/components/FilesNavigationItem.vue
@@ -89,7 +89,7 @@ export default defineComponent({
return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[])
.filter(view => view.params?.dir.startsWith(this.parent.params?.dir))
}
- return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids
+ return this.filterVisible(this.views[this.parent.id] ?? [])
},
style() {
@@ -103,11 +103,15 @@ export default defineComponent({
},
methods: {
+ filterVisible(views: View[]) {
+ return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true)
+ },
+
hasChildViews(view: View): boolean {
if (this.level >= maxLevel) {
return false
}
- return this.views[view.id]?.length > 0
+ return this.filterVisible(this.views[view.id] ?? []).length > 0
},
/**
diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue
new file mode 100644
index 00000000000..e34d4bf0971
--- /dev/null
+++ b/apps/files/src/components/FilesNavigationSearch.vue
@@ -0,0 +1,86 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import { mdiMagnify, mdiSearchWeb } from '@mdi/js'
+import { t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts'
+import { useNavigation } from '../composables/useNavigation.ts'
+import { useSearchStore } from '../store/search.ts'
+import { VIEW_ID } from '../views/search.ts'
+
+const { currentView } = useNavigation(true)
+const searchStore = useSearchStore()
+
+/**
+ * When the route is changed from search view to something different
+ * we need to clear the search box.
+ */
+onBeforeNavigation((to, from, next) => {
+ if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) {
+ // we are leaving the search view so unset the query
+ searchStore.query = ''
+ searchStore.scope = 'filter'
+ } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) {
+ // fix the query if the user refreshed the view
+ if (searchStore.query && !to.query.query) {
+ // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3)
+ return next({
+ ...to,
+ query: {
+ ...to.query,
+ query: searchStore.query,
+ },
+ })
+ }
+ }
+ next()
+})
+
+/**
+ * Are we currently on the search view.
+ * Needed to disable the action menu (we cannot change the search mode there)
+ */
+const isSearchView = computed(() => currentView.value.id === VIEW_ID)
+
+/**
+ * Different searchbox label depending if filtering or searching
+ */
+const searchLabel = computed(() => {
+ if (searchStore.scope === 'globally') {
+ return t('files', 'Search globally by filename …')
+ }
+ return t('files', 'Search here by filename …')
+})
+</script>
+
+<template>
+ <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel">
+ <template #actions>
+ <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView">
+ <template #icon>
+ <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" />
+ </template>
+ <NcActionButton close-after-click @click="searchStore.scope = 'filter'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ {{ t('files', 'Filter and search from this location') }}
+ </NcActionButton>
+ <NcActionButton close-after-click @click="searchStore.scope = 'globally'">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiSearchWeb" />
+ </template>
+ {{ t('files', 'Search globally') }}
+ </NcActionButton>
+ </NcActions>
+ </template>
+ </NcAppNavigationSearch>
+</template>
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index f1d2738e81d..fd10af1c495 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -58,7 +58,7 @@ export default {
computed: {
storageStatsTitle() {
const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false)
- const quotaByte = formatFileSize(this.storageStats?.quota, false, false)
+ const quotaByte = formatFileSize(this.storageStats?.total, false, false)
// If no quota set
if (this.storageStats?.quota < 0) {
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 5ae8220d594..4f9d8096580 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -20,7 +20,18 @@
<slot name="header-overlay" />
</div>
- <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }">
+ <div v-if="dataSources.length === 0"
+ class="files-list__empty">
+ <slot name="empty" />
+ </div>
+
+ <table :aria-hidden="dataSources.length === 0"
+ :inert="dataSources.length === 0"
+ class="files-list__table"
+ :class="{
+ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'],
+ 'files-list__table--hidden': dataSources.length === 0,
+ }">
<!-- Accessibility table caption for screen readers -->
<caption v-if="caption" class="hidden-visually">
{{ caption }}
@@ -309,7 +320,7 @@ export default defineComponent({
methods: {
scrollTo(index: number) {
- if (!this.$el) {
+ if (!this.$el || this.index === index) {
return
}
diff --git a/apps/files/src/composables/useBeforeNavigation.ts b/apps/files/src/composables/useBeforeNavigation.ts
new file mode 100644
index 00000000000..38b72e40fb3
--- /dev/null
+++ b/apps/files/src/composables/useBeforeNavigation.ts
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { NavigationGuard } from 'vue-router'
+
+import { onUnmounted } from 'vue'
+import { useRouter } from 'vue-router/composables'
+
+/**
+ * Helper until we use Vue-Router v4 (Vue3).
+ *
+ * @param fn - The navigation guard
+ */
+export function onBeforeNavigation(fn: NavigationGuard) {
+ const router = useRouter()
+ const remove = router.beforeResolve(fn)
+ onUnmounted(remove)
+}
diff --git a/apps/files/src/composables/useFilenameFilter.ts b/apps/files/src/composables/useFilenameFilter.ts
deleted file mode 100644
index 54c16f35384..00000000000
--- a/apps/files/src/composables/useFilenameFilter.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/*!
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files'
-import { watchThrottled } from '@vueuse/core'
-import { onMounted, onUnmounted, ref } from 'vue'
-import { FilenameFilter } from '../filters/FilenameFilter'
-
-/**
- * This is for the `Navigation` component to provide a filename filter
- */
-export function useFilenameFilter() {
- const searchQuery = ref('')
- const filenameFilter = new FilenameFilter()
-
- /**
- * Updating the search query ref from the filter
- * @param event The update:query event
- */
- function updateQuery(event: CustomEvent) {
- if (event.type === 'update:query') {
- searchQuery.value = event.detail
- event.stopPropagation()
- }
- }
-
- onMounted(() => {
- filenameFilter.addEventListener('update:query', updateQuery)
- registerFileListFilter(filenameFilter)
- })
- onUnmounted(() => {
- filenameFilter.removeEventListener('update:query', updateQuery)
- unregisterFileListFilter(filenameFilter.id)
- })
-
- // Update the query on the filter, but throttle to max. every 800ms
- // This will debounce the filter refresh
- watchThrottled(searchQuery, () => {
- filenameFilter.updateQuery(searchQuery.value)
- }, { throttle: 800 })
-
- return {
- searchQuery,
- }
-}
diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts
index fb61b4a6d03..ab8dbb63dfc 100644
--- a/apps/files/src/eventbus.d.ts
+++ b/apps/files/src/eventbus.d.ts
@@ -2,7 +2,9 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { IFileListFilter, Node } from '@nextcloud/files'
+
+import type { IFileListFilter, Node, View } from '@nextcloud/files'
+import type { SearchScope } from './types'
declare module '@nextcloud/event-bus' {
export interface NextcloudEvents {
@@ -13,8 +15,13 @@ declare module '@nextcloud/event-bus' {
'files:favorites:removed': Node
'files:favorites:added': Node
+ 'files:filter:added': IFileListFilter
+ 'files:filter:removed': string
+ // the state of some filters has changed
'files:filters:changed': undefined
+ 'files:navigation:changed': View
+
'files:node:created': Node
'files:node:deleted': Node
'files:node:updated': Node
@@ -22,8 +29,7 @@ declare module '@nextcloud/event-bus' {
'files:node:renamed': Node
'files:node:moved': { node: Node, oldSource: string }
- 'files:filter:added': IFileListFilter
- 'files:filter:removed': string
+ 'files:search:updated': { query: string, scope: SearchScope }
}
}
diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts
index 5019ca42d83..f86269ccd99 100644
--- a/apps/files/src/filters/FilenameFilter.ts
+++ b/apps/files/src/filters/FilenameFilter.ts
@@ -4,17 +4,33 @@
*/
import type { IFileListFilterChip, INode } from '@nextcloud/files'
-import { FileListFilter } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { getPinia } from '../store/index.ts'
+import { useSearchStore } from '../store/search.ts'
+
+/**
+ * Register the filename filter
+ */
+export function registerFilenameFilter() {
+ registerFileListFilter(new FilenameFilter())
+}
/**
* Simple file list filter controlled by the Navigation search box
*/
-export class FilenameFilter extends FileListFilter {
+class FilenameFilter extends FileListFilter {
private searchQuery = ''
constructor() {
super('files:filename', 5)
+ subscribe('files:search:updated', ({ query, scope }) => {
+ if (scope === 'filter') {
+ this.updateQuery(query)
+ }
+ })
}
public filter(nodes: INode[]): INode[] {
@@ -45,10 +61,14 @@ export class FilenameFilter extends FileListFilter {
this.updateQuery('')
},
})
+ } else {
+ // make sure to also reset the search store when pressing the "X" on the filter chip
+ const store = useSearchStore(getPinia())
+ if (store.scope === 'filter') {
+ store.query = ''
+ }
}
this.updateChips(chips)
- // Emit the new query as it might have come not from the Navigation
- this.dispatchTypedEvent('update:query', new CustomEvent('update:query', { detail: query }))
}
}
diff --git a/apps/files/src/filters/SearchFilter.ts b/apps/files/src/filters/SearchFilter.ts
new file mode 100644
index 00000000000..4c7231fd26a
--- /dev/null
+++ b/apps/files/src/filters/SearchFilter.ts
@@ -0,0 +1,49 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ComponentPublicInstance } from 'vue'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import Vue from 'vue'
+import FileListFilterToSearch from '../components/FileListFilter/FileListFilterToSearch.vue'
+
+class SearchFilter extends FileListFilter {
+
+ private currentInstance?: ComponentPublicInstance<typeof FileListFilterToSearch>
+
+ constructor() {
+ super('files:filter-to-search', 999)
+ subscribe('files:search:updated', ({ query, scope }) => {
+ if (query && scope === 'filter') {
+ this.currentInstance?.showButton()
+ } else {
+ this.currentInstance?.hideButton()
+ }
+ })
+ }
+
+ public mount(el: HTMLElement) {
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ }
+
+ const View = Vue.extend(FileListFilterToSearch)
+ this.currentInstance = new View().$mount(el) as unknown as ComponentPublicInstance<typeof FileListFilterToSearch>
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ return nodes
+ }
+
+}
+
+/**
+ * Register a file list filter to only show hidden files if enabled by user config
+ */
+export function registerFilterToSearchToggle() {
+ registerFileListFilter(new SearchFilter())
+}
diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts
index 492ffbb1915..74eca0969b4 100644
--- a/apps/files/src/init.ts
+++ b/apps/files/src/init.ts
@@ -25,14 +25,18 @@ import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
import { registerFavoritesView } from './views/favorites.ts'
import registerRecentView from './views/recent'
-import registerPersonalFilesView from './views/personal-files'
-import registerFilesView from './views/files'
+import { registerPersonalFilesView } from './views/personal-files'
+import { registerFilesView } from './views/files'
import { registerFolderTreeView } from './views/folderTree.ts'
+import { registerSearchView } from './views/search.ts'
+
import registerPreviewServiceWorker from './services/ServiceWorker.js'
import { initLivePhotos } from './services/LivePhotos'
import { isPublicShare } from '@nextcloud/sharing/public'
import { registerConvertActions } from './actions/convertAction.ts'
+import { registerFilenameFilter } from './filters/FilenameFilter.ts'
+import { registerFilterToSearchToggle } from './filters/SearchFilter.ts'
// Register file actions
registerConvertActions()
@@ -56,8 +60,9 @@ registerTemplateEntries()
if (isPublicShare() === false) {
registerFavoritesView()
registerFilesView()
- registerRecentView()
registerPersonalFilesView()
+ registerRecentView()
+ registerSearchView()
registerFolderTreeView()
}
@@ -65,6 +70,8 @@ if (isPublicShare() === false) {
registerHiddenFilesFilter()
registerTypeFilter()
registerModifiedFilter()
+registerFilenameFilter()
+registerFilterToSearchToggle()
// Register preview service worker
registerPreviewServiceWorker()
diff --git a/apps/files/src/router/router.ts b/apps/files/src/router/router.ts
index 13e74c26451..fccb4a0a2b2 100644
--- a/apps/files/src/router/router.ts
+++ b/apps/files/src/router/router.ts
@@ -3,11 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { RawLocation, Route } from 'vue-router'
+
import { generateUrl } from '@nextcloud/router'
+import { relative } from 'path'
import queryString from 'query-string'
import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
import Vue from 'vue'
-import logger from '../logger'
+
+import { useFilesStore } from '../store/files.ts'
+import { usePathsStore } from '../store/paths.ts'
+import { defaultView } from '../utils/filesViews.ts'
+import logger from '../logger.ts'
Vue.use(Router)
@@ -52,7 +58,7 @@ const router = new Router({
{
path: '/',
// Pretending we're using the default view
- redirect: { name: 'filelist', params: { view: 'files' } },
+ redirect: { name: 'filelist', params: { view: defaultView() } },
},
{
path: '/:view/:fileid(\\d+)?',
@@ -68,4 +74,72 @@ const router = new Router({
},
})
+// Handle aborted navigation (NavigationGuards) gracefully
+router.onError((error) => {
+ if (isNavigationFailure(error, NavigationFailureType.aborted)) {
+ logger.debug('Navigation was aboorted', { error })
+ } else {
+ throw error
+ }
+})
+
+// If navigating back from a folder to a parent folder,
+// we need to keep the current dir fileid so it's highlighted
+// and scrolled into view.
+router.beforeResolve((to, from, next) => {
+ if (to.params?.parentIntercept) {
+ delete to.params.parentIntercept
+ return next()
+ }
+
+ if (to.params.view !== from.params.view) {
+ // skip if different views
+ return next()
+ }
+
+ const fromDir = (from.query?.dir || '/') as string
+ const toDir = (to.query?.dir || '/') as string
+
+ // We are going back to a parent directory
+ if (relative(fromDir, toDir) === '..') {
+ const { getNode } = useFilesStore()
+ const { getPath } = usePathsStore()
+
+ if (!from.params.view) {
+ logger.error('No current view id found, cannot navigate to parent directory', { fromDir, toDir })
+ return next()
+ }
+
+ // Get the previous parent's file id
+ const fromSource = getPath(from.params.view, fromDir)
+ if (!fromSource) {
+ logger.error('No source found for the parent directory', { fromDir, toDir })
+ return next()
+ }
+
+ const fileId = getNode(fromSource)?.fileid
+ if (!fileId) {
+ logger.error('No fileid found for the parent directory', { fromDir, toDir, fromSource })
+ return next()
+ }
+
+ logger.debug('Navigating back to parent directory', { fromDir, toDir, fileId })
+ return next({
+ name: 'filelist',
+ query: to.query,
+ params: {
+ ...to.params,
+ fileid: String(fileId),
+ // Prevents the beforeEach from being called again
+ parentIntercept: 'true',
+ },
+ // Replace the current history entry
+ replace: true,
+ })
+ }
+
+ // else, we just continue
+ next()
+})
+
export default router
diff --git a/apps/files/src/services/FileInfo.ts b/apps/files/src/services/FileInfo.ts
index 18629845cca..318236f1677 100644
--- a/apps/files/src/services/FileInfo.ts
+++ b/apps/files/src/services/FileInfo.ts
@@ -24,6 +24,7 @@ export default function(node: Node) {
sharePermissions: node.attributes['share-permissions'],
shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'),
type: node.type === 'file' ? 'file' : 'dir',
+ attributes: node.attributes,
})
// TODO remove when no more legacy backbone is used
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
index f02b48f64f3..080ce91e538 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -2,25 +2,55 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ContentsWithRoot, File, Folder } from '@nextcloud/files'
+import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
-import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
+import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { join } from 'path'
import { client } from './WebdavClient.ts'
+import { searchNodes } from './WebDavSearch.ts'
+import { getPinia } from '../store/index.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSearchStore } from '../store/search.ts'
import logger from '../logger.ts'
-
/**
* Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
* @param stat The result returned by the webdav library
*/
-export const resultToNode = (stat: FileStat): File | Folder => davResultToNode(stat)
+export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
-export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
- path = join(davRootPath, path)
+/**
+ * Get contents implementation for the files view.
+ * This also allows to fetch local search results when the user is currently filtering.
+ *
+ * @param path - The path to query
+ */
+export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
const controller = new AbortController()
- const propfindPayload = davGetDefaultPropfind()
+ const searchStore = useSearchStore(getPinia())
+
+ if (searchStore.query.length >= 3) {
+ return new CancelablePromise((resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ getLocalSearch(path, searchStore.query, controller.signal)
+ .then(resolve)
+ .catch(reject)
+ })
+ } else {
+ return defaultGetContents(path)
+ }
+}
+
+/**
+ * Generic `getContents` implementation for the users files.
+ *
+ * @param path - The path to get the contents
+ */
+export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> {
+ path = join(defaultRootPath, path)
+ const controller = new AbortController()
+ const propfindPayload = getDefaultPropfind()
return new CancelablePromise(async (resolve, reject, onCancel) => {
onCancel(() => controller.abort())
@@ -56,3 +86,25 @@ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> =>
}
})
}
+
+/**
+ * Get the local search results for the current folder.
+ *
+ * @param path - The path
+ * @param query - The current search query
+ * @param signal - The aboort signal
+ */
+async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> {
+ const filesStore = useFilesStore(getPinia())
+ let folder = filesStore.getDirectoryByPath('files', path)
+ if (!folder) {
+ const rootPath = join(defaultRootPath, path)
+ const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat>
+ folder = resultToNode(stat.data) as Folder
+ }
+ const contents = await searchNodes(query, { dir: path, signal })
+ return {
+ folder,
+ contents,
+ }
+}
diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts
index c732c728ce5..92430c8e6ad 100644
--- a/apps/files/src/services/HotKeysService.spec.ts
+++ b/apps/files/src/services/HotKeysService.spec.ts
@@ -2,13 +2,14 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { File, Permission, View } from '@nextcloud/files'
+import { File, Folder, Permission, View } from '@nextcloud/files'
import { describe, it, vi, expect, beforeEach, beforeAll, afterEach } from 'vitest'
import { nextTick } from 'vue'
import axios from '@nextcloud/axios'
import { getPinia } from '../store/index.ts'
import { useActiveStore } from '../store/active.ts'
+import { useFilesStore } from '../store/files'
import { action as deleteAction } from '../actions/deleteAction.ts'
import { action as favoriteAction } from '../actions/favoriteAction.ts'
@@ -49,18 +50,23 @@ describe('HotKeysService testing', () => {
// Make sure the file is reset before each test
file = new File({
- id: 1,
+ id: 2,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
})
+ const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE })
+ const files = useFilesStore(getPinia())
+ files.setRoot({ service: 'files', root })
+
// Setting the view first as it reset the active node
- activeStore.onChangedView(view)
- activeStore.setActiveNode(file)
+ activeStore.activeView = view
+ activeStore.activeNode = file
window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } }
+ // We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } }
initialState = document.createElement('input')
@@ -73,26 +79,26 @@ describe('HotKeysService testing', () => {
})
it('Pressing d should open the sidebar once', () => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD' }))
+ dispatchEvent({ key: 'd', code: 'KeyD' })
// Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', metaKey: true }))
+ dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', altKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true })
+ dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true })
expect(sidebarAction.enabled).toHaveReturnedWith(true)
expect(sidebarAction.exec).toHaveBeenCalledOnce()
})
it('Pressing F2 should rename the file', () => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2' }))
+ dispatchEvent({ key: 'F2', code: 'F2' })
// Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', metaKey: true }))
+ dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', altKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true })
+ dispatchEvent({ key: 'F2', code: 'F2', metaKey: true })
expect(renameAction.enabled).toHaveReturnedWith(true)
expect(renameAction.exec).toHaveBeenCalledOnce()
@@ -100,29 +106,29 @@ describe('HotKeysService testing', () => {
it('Pressing s should toggle favorite', () => {
vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS' }))
+ dispatchEvent({ key: 's', code: 'KeyS' })
// Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true }))
+ dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', altKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true })
+ dispatchEvent({ key: 's', code: 'KeyS', metaKey: true })
expect(favoriteAction.enabled).toHaveReturnedWith(true)
expect(favoriteAction.exec).toHaveBeenCalledOnce()
})
it('Pressing Delete should delete the file', async () => {
- // @ts-expect-error mocking private field
+ // @ts-expect-error unit testing
vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete' }))
+ dispatchEvent({ key: 'Delete', code: 'Delete' })
// Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true }))
+ dispatchEvent({ key: 'Delete', code: 'Delete', ctrlKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', altKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', shiftKey: true })
+ dispatchEvent({ key: 'Delete', code: 'Delete', metaKey: true })
expect(deleteAction.enabled).toHaveReturnedWith(true)
expect(deleteAction.exec).toHaveBeenCalledOnce()
@@ -132,7 +138,7 @@ describe('HotKeysService testing', () => {
expect(goToRouteMock).toHaveBeenCalledTimes(0)
window.OCP.Files.Router.query = { dir: '/foo/bar' }
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', altKey: true }))
+ dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true })
expect(goToRouteMock).toHaveBeenCalledOnce()
expect(goToRouteMock.mock.calls[0][2].dir).toBe('/foo')
@@ -145,9 +151,7 @@ describe('HotKeysService testing', () => {
userConfigStore.userConfig.grid_view = false
expect(userConfigStore.userConfig.grid_view).toBe(false)
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV' }))
- await nextTick()
-
+ dispatchEvent({ key: 'v', code: 'KeyV' })
expect(userConfigStore.userConfig.grid_view).toBe(true)
})
@@ -164,9 +168,19 @@ describe('HotKeysService testing', () => {
userConfigStore.userConfig.grid_view = false
expect(userConfigStore.userConfig.grid_view).toBe(false)
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true }))
+ dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true }))
+
await nextTick()
expect(userConfigStore.userConfig.grid_view).toBe(false)
})
})
+
+/**
+ * Helper to dispatch the correct event.
+ *
+ * @param init - KeyboardEvent options
+ */
+function dispatchEvent(init: KeyboardEventInit) {
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true }))
+}
diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts
new file mode 100644
index 00000000000..c2840521a15
--- /dev/null
+++ b/apps/files/src/services/Search.spec.ts
@@ -0,0 +1,61 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getContents } from './Search.ts'
+import { Folder, Permission } from '@nextcloud/files'
+
+const searchNodes = vi.hoisted(() => vi.fn())
+vi.mock('./WebDavSearch.ts', () => ({ searchNodes }))
+vi.mock('@nextcloud/auth')
+
+describe('Search service', () => {
+ const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' })
+
+ beforeAll(() => {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router ??= { params: {}, query: {} }
+ vi.spyOn(window.OCP.Files.Router, 'params', 'get').mockReturnValue({ view: 'files' })
+ })
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setActivePinia(createPinia())
+ })
+
+ it('rejects on error', async () => {
+ searchNodes.mockImplementationOnce(() => { throw new Error('expected error') })
+ expect(getContents).rejects.toThrow('expected error')
+ })
+
+ it('returns the search results and a fake root', async () => {
+ searchNodes.mockImplementationOnce(() => [fakeFolder])
+ const { contents, folder } = await getContents()
+
+ expect(searchNodes).toHaveBeenCalledOnce()
+ expect(contents).toHaveLength(1)
+ expect(contents).toEqual([fakeFolder])
+ // read only root
+ expect(folder.permissions).toBe(Permission.READ)
+ })
+
+ it('can be cancelled', async () => {
+ const { promise, resolve } = Promise.withResolvers<Event>()
+ searchNodes.mockImplementationOnce(async (_, { signal }: { signal: AbortSignal}) => {
+ signal.addEventListener('abort', resolve)
+ await promise
+ return []
+ })
+
+ const content = getContents()
+ content.cancel()
+
+ // its cancelled thus the promise returns the event
+ const event = await promise
+ expect(event.type).toBe('abort')
+ })
+})
diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts
new file mode 100644
index 00000000000..f1d7c30a94e
--- /dev/null
+++ b/apps/files/src/services/Search.ts
@@ -0,0 +1,43 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission } from '@nextcloud/files'
+import { defaultRemoteURL } from '@nextcloud/files/dav'
+import { CancelablePromise } from 'cancelable-promise'
+import { searchNodes } from './WebDavSearch.ts'
+import logger from '../logger.ts'
+import { useSearchStore } from '../store/search.ts'
+import { getPinia } from '../store/index.ts'
+
+/**
+ * Get the contents for a search view
+ */
+export function getContents(): CancelablePromise<ContentsWithRoot> {
+ const controller = new AbortController()
+
+ const searchStore = useSearchStore(getPinia())
+
+ return new CancelablePromise<ContentsWithRoot>(async (resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ try {
+ const contents = await searchNodes(searchStore.query, { signal: controller.signal })
+ resolve({
+ contents,
+ folder: new Folder({
+ id: 0,
+ source: `${defaultRemoteURL}#search`,
+ owner: getCurrentUser()!.uid,
+ permissions: Permission.READ,
+ }),
+ })
+ } catch (error) {
+ logger.error('Failed to fetch search results', { error })
+ reject(error)
+ }
+ })
+}
diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js
index 3a0a0fdb809..d7f25846ceb 100644
--- a/apps/files/src/services/Templates.js
+++ b/apps/files/src/services/Templates.js
@@ -11,6 +11,11 @@ export const getTemplates = async function() {
return response.data.ocs.data
}
+export const getTemplateFields = async function(fileId) {
+ const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`))
+ return response.data.ocs.data
+}
+
/**
* Create a new file from a specified template
*
diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts
new file mode 100644
index 00000000000..feb7f30b357
--- /dev/null
+++ b/apps/files/src/services/WebDavSearch.ts
@@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ResponseDataDetailed, SearchResult } from 'webdav'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { defaultRootPath, getDavNameSpaces, getDavProperties, resultToNode } from '@nextcloud/files/dav'
+import { getBaseUrl } from '@nextcloud/router'
+import { client } from './WebdavClient.ts'
+import logger from '../logger.ts'
+
+export interface SearchNodesOptions {
+ dir?: string,
+ signal?: AbortSignal
+}
+
+/**
+ * Search for nodes matching the given query.
+ *
+ * @param query - Search query
+ * @param options - Options
+ * @param options.dir - The base directory to scope the search to
+ * @param options.signal - Abort signal for the request
+ */
+export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise<INode[]> {
+ const user = getCurrentUser()
+ if (!user) {
+ // the search plugin only works for user roots
+ return []
+ }
+
+ query = query.trim()
+ if (query.length < 3) {
+ // the search plugin only works with queries of at least 3 characters
+ return []
+ }
+
+ if (dir && !dir.startsWith('/')) {
+ dir = `/${dir}`
+ }
+
+ logger.debug('Searching for nodes', { query, dir })
+ const { data } = await client.search('/', {
+ details: true,
+ signal,
+ data: `
+<d:searchrequest ${getDavNameSpaces()}>
+ <d:basicsearch>
+ <d:select>
+ <d:prop>
+ ${getDavProperties()}
+ </d:prop>
+ </d:select>
+ <d:from>
+ <d:scope>
+ <d:href>/files/${user.uid}${dir || ''}</d:href>
+ <d:depth>infinity</d:depth>
+ </d:scope>
+ </d:from>
+ <d:where>
+ <d:like>
+ <d:prop>
+ <d:displayname/>
+ </d:prop>
+ <d:literal>%${query.replace('%', '')}%</d:literal>
+ </d:like>
+ </d:where>
+ <d:orderby/>
+ </d:basicsearch>
+</d:searchrequest>`,
+ }) as ResponseDataDetailed<SearchResult>
+
+ // check if the request was aborted
+ if (signal?.aborted) {
+ return []
+ }
+
+ // otherwise return the result mapped to Nextcloud nodes
+ return data.results.map((result) => resultToNode(result, defaultRootPath, getBaseUrl()))
+}
diff --git a/apps/files/src/store/active.ts b/apps/files/src/store/active.ts
index e261e817f3d..1303a157b08 100644
--- a/apps/files/src/store/active.ts
+++ b/apps/files/src/store/active.ts
@@ -3,74 +3,84 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ActiveStore } from '../types.ts'
-import type { FileAction, Node, View } from '@nextcloud/files'
+import type { FileAction, View, Node, Folder } from '@nextcloud/files'
-import { defineStore } from 'pinia'
-import { getNavigation } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
+import { getNavigation } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
import logger from '../logger.ts'
-export const useActiveStore = function(...args) {
- const store = defineStore('active', {
- state: () => ({
- _initialized: false,
- activeNode: null,
- activeView: null,
- activeAction: null,
- } as ActiveStore),
+export const useActiveStore = defineStore('active', () => {
+ /**
+ * The currently active action
+ */
+ const activeAction = ref<FileAction>()
- actions: {
- setActiveNode(node: Node) {
- if (!node) {
- throw new Error('Use clearActiveNode to clear the active node')
- }
- logger.debug('Setting active node', { node })
- this.activeNode = node
- },
+ /**
+ * The currently active folder
+ */
+ const activeFolder = ref<Folder>()
- clearActiveNode() {
- this.activeNode = null
- },
+ /**
+ * The current active node within the folder
+ */
+ const activeNode = ref<Node>()
- onDeletedNode(node: Node) {
- if (this.activeNode && this.activeNode.source === node.source) {
- this.clearActiveNode()
- }
- },
+ /**
+ * The current active view
+ */
+ const activeView = ref<View>()
- setActiveAction(action: FileAction) {
- this.activeAction = action
- },
+ initialize()
- clearActiveAction() {
- this.activeAction = null
- },
+ /**
+ * Unset the active node if deleted
+ *
+ * @param node - The node thats deleted
+ * @private
+ */
+ function onDeletedNode(node: Node) {
+ if (activeNode.value && activeNode.value.source === node.source) {
+ activeNode.value = undefined
+ }
+ }
- onChangedView(view: View|null = null) {
- logger.debug('Setting active view', { view })
- this.activeView = view
- this.clearActiveNode()
- },
- },
- })
+ /**
+ * Callback to update the current active view
+ *
+ * @param view - The new active view
+ * @private
+ */
+ function onChangedView(view: View|null = null) {
+ logger.debug('Setting active view', { view })
+ activeView.value = view ?? undefined
+ activeNode.value = undefined
+ }
- const activeStore = store(...args)
- const navigation = getNavigation()
+ /**
+ * Initalize the store - connect all event listeners.
+ * @private
+ */
+ function initialize() {
+ const navigation = getNavigation()
- // Make sure we only register the listeners once
- if (!activeStore._initialized) {
- subscribe('files:node:deleted', activeStore.onDeletedNode)
+ // Make sure we only register the listeners once
+ subscribe('files:node:deleted', onDeletedNode)
- activeStore._initialized = true
- activeStore.onChangedView(navigation.active)
+ onChangedView(navigation.active)
// Or you can react to changes of the current active view
navigation.addEventListener('updateActive', (event) => {
- activeStore.onChangedView(event.detail)
+ onChangedView(event.detail)
})
}
- return activeStore
-}
+ return {
+ activeAction,
+ activeFolder,
+ activeNode,
+ activeView,
+ }
+})
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index 295704c880b..0bcf4ce9350 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -54,13 +54,13 @@ export const useFilesStore = function(...args) {
actions: {
/**
- * Get cached child nodes within a given path
+ * Get cached directory matching a given path
*
- * @param service The service (files view)
- * @param path The path relative within the service
- * @return Array of cached nodes within the path
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return The folder if found
*/
- getNodesByPath(service: string, path?: string): Node[] {
+ getDirectoryByPath(service: string, path?: string): Folder | undefined {
const pathsStore = usePathsStore()
let folder: Folder | undefined
@@ -74,6 +74,19 @@ export const useFilesStore = function(...args) {
}
}
+ return folder
+ },
+
+ /**
+ * Get cached child nodes within a given path
+ *
+ * @param service - The service (files view)
+ * @param path - The path relative within the service
+ * @return Array of cached nodes within the path
+ */
+ getNodesByPath(service: string, path?: string): Node[] {
+ const folder = this.getDirectoryByPath(service, path)
+
// If we found a cache entry and the cache entry was already loaded (has children) then use it
return (folder?._children ?? [])
.map((source: string) => this.getNode(source))
@@ -141,7 +154,7 @@ export const useFilesStore = function(...args) {
}
// If we have only one node with the file ID, we can update it directly
- if (node.source === nodes[0].source) {
+ if (nodes.length === 1 && node.source === nodes[0].source) {
this.updateNodes([node])
return
}
diff --git a/apps/files/src/store/renaming.ts b/apps/files/src/store/renaming.ts
index 2ac9e06ba16..fc61be3bd3b 100644
--- a/apps/files/src/store/renaming.ts
+++ b/apps/files/src/store/renaming.ts
@@ -14,6 +14,7 @@ import { defineStore } from 'pinia'
import logger from '../logger'
import Vue, { defineAsyncComponent, ref } from 'vue'
import { useUserConfigStore } from './userconfig'
+import { fetchNode } from '../services/WebdavClient'
export const useRenamingStore = defineStore('renaming', () => {
/**
@@ -48,7 +49,7 @@ export const useRenamingStore = defineStore('renaming', () => {
}
isRenaming.value = true
- const node = renamingNode.value
+ let node = renamingNode.value
Vue.set(node, 'status', NodeStatus.LOADING)
const userConfig = useUserConfigStore()
@@ -86,6 +87,13 @@ export const useRenamingStore = defineStore('renaming', () => {
},
})
+ // Update mime type if extension changed
+ // as other related informations might have changed
+ // on the backend but it is really hard to know on the front
+ if (oldExtension !== newExtension) {
+ node = await fetchNode(node.path)
+ }
+
// Success 🎉
emit('files:node:updated', node)
emit('files:node:renamed', node)
diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts
new file mode 100644
index 00000000000..43e01f35b92
--- /dev/null
+++ b/apps/files/src/store/search.ts
@@ -0,0 +1,153 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { View } from '@nextcloud/files'
+import type RouterService from '../services/RouterService.ts'
+import type { SearchScope } from '../types.ts'
+
+import { emit, subscribe } from '@nextcloud/event-bus'
+import debounce from 'debounce'
+import { defineStore } from 'pinia'
+import { ref, watch } from 'vue'
+import { VIEW_ID } from '../views/search.ts'
+import logger from '../logger.ts'
+
+export const useSearchStore = defineStore('search', () => {
+ /**
+ * The current search query
+ */
+ const query = ref('')
+
+ /**
+ * Scope of the search.
+ * Scopes:
+ * - filter: only filter current file list
+ * - globally: search everywhere
+ */
+ const scope = ref<SearchScope>('filter')
+
+ // reset the base if query is cleared
+ watch(scope, updateSearch)
+
+ watch(query, (old, current) => {
+ // skip if only whitespaces changed
+ if (old.trim() === current.trim()) {
+ return
+ }
+
+ updateSearch()
+ })
+
+ // initialize the search store
+ initialize()
+
+ /**
+ * Debounced update of the current route
+ * @private
+ */
+ const updateRouter = debounce((isSearch: boolean) => {
+ const router = window.OCP.Files.Router as RouterService
+ router.goToRoute(
+ undefined,
+ {
+ view: VIEW_ID,
+ },
+ {
+ query: query.value,
+ },
+ isSearch,
+ )
+ })
+
+ /**
+ * Handle updating the filter if needed.
+ * Also update the search view by updating the current route if needed.
+ *
+ * @private
+ */
+ function updateSearch() {
+ // emit the search event to update the filter
+ emit('files:search:updated', { query: query.value, scope: scope.value })
+ const router = window.OCP.Files.Router as RouterService
+
+ // if we are on the search view and the query was unset or scope was set to 'filter' we need to move back to the files view
+ if (router.params.view === VIEW_ID && (query.value === '' || scope.value === 'filter')) {
+ scope.value = 'filter'
+ return router.goToRoute(
+ undefined,
+ {
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ )
+ }
+
+ // for the filter scope we do not need to adjust the current route anymore
+ // also if the query is empty we do not need to do anything
+ if (scope.value === 'filter' || !query.value) {
+ return
+ }
+
+ const isSearch = router.params.view === VIEW_ID
+
+ logger.debug('Update route for updated search query', { query: query.value, isSearch })
+ updateRouter(isSearch)
+ }
+
+ /**
+ * Event handler that resets the store if the file list view was changed.
+ *
+ * @param view - The new view that is active
+ * @private
+ */
+ function onViewChanged(view: View) {
+ if (view.id !== VIEW_ID) {
+ query.value = ''
+ scope.value = 'filter'
+ }
+ }
+
+ /**
+ * Initialize the store from the router if needed
+ */
+ function initialize() {
+ subscribe('files:navigation:changed', onViewChanged)
+
+ const router = window.OCP.Files.Router as RouterService
+ // if we initially load the search view (e.g. hard page refresh)
+ // then we need to initialize the store from the router
+ if (router.params.view === VIEW_ID) {
+ query.value = [router.query.query].flat()[0] ?? ''
+
+ if (query.value) {
+ scope.value = 'globally'
+ logger.debug('Directly navigated to search view', { query: query.value })
+ } else {
+ // we do not have any query so we need to move to the files list
+ logger.info('Directly navigated to search view without any query, redirect to files view.')
+ router.goToRoute(
+ undefined,
+ {
+ ...router.params,
+ view: 'files',
+ },
+ {
+ ...router.query,
+ query: undefined,
+ },
+ true,
+ )
+ }
+ }
+ }
+
+ return {
+ query,
+ scope,
+ }
+})
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
index d84a5e0d935..a901ab9c593 100644
--- a/apps/files/src/store/userconfig.ts
+++ b/apps/files/src/store/userconfig.ts
@@ -12,11 +12,13 @@ import { ref, set } from 'vue'
import axios from '@nextcloud/axios'
const initialUserConfig = loadState<UserConfig>('files', 'config', {
- show_hidden: false,
crop_image_previews: true,
+ default_view: 'files',
+ grid_view: false,
+ show_hidden: false,
+ show_mime_column: true,
sort_favorites_first: true,
sort_folders_first: true,
- grid_view: false,
show_dialog_file_extension: true,
})
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index 4bf8a557f49..d2d1fe41648 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -50,15 +50,18 @@ export interface PathOptions {
// User config store
export interface UserConfig {
- [key: string]: boolean|undefined
+ [key: string]: boolean | string | undefined
+ crop_image_previews: boolean
+ default_view: 'files' | 'personal'
+ grid_view: boolean
show_dialog_file_extension: boolean,
show_hidden: boolean
- crop_image_previews: boolean
+ show_mime_column: boolean
sort_favorites_first: boolean
sort_folders_first: boolean
- grid_view: boolean
}
+
export interface UserConfigStore {
userConfig: UserConfig
}
@@ -104,12 +107,17 @@ export interface DragAndDropStore {
// Active node store
export interface ActiveStore {
- _initialized: boolean
+ activeAction: FileAction|null
+ activeFolder: Folder|null
activeNode: Node|null
activeView: View|null
- activeAction: FileAction|null
}
+/**
+ * Search scope for the in-files-search
+ */
+export type SearchScope = 'filter'|'globally'
+
export interface TemplateFile {
app: string
label: string
diff --git a/apps/files/src/utils/actionUtils.ts b/apps/files/src/utils/actionUtils.ts
index 730a1149229..f6d43727c29 100644
--- a/apps/files/src/utils/actionUtils.ts
+++ b/apps/files/src/utils/actionUtils.ts
@@ -49,7 +49,7 @@ export const executeAction = async (action: FileAction) => {
try {
// Set the loading marker
Vue.set(currentNode, 'status', NodeStatus.LOADING)
- activeStore.setActiveAction(action)
+ activeStore.activeAction = action
const success = await action.exec(currentNode, currentView, currentDir)
@@ -69,6 +69,6 @@ export const executeAction = async (action: FileAction) => {
} finally {
// Reset the loading marker
Vue.set(currentNode, 'status', undefined)
- activeStore.clearActiveAction()
+ activeStore.activeAction = undefined
}
}
diff --git a/apps/files/src/utils/fileUtils.ts b/apps/files/src/utils/fileUtils.ts
index 421b7d02376..f0b974be21d 100644
--- a/apps/files/src/utils/fileUtils.ts
+++ b/apps/files/src/utils/fileUtils.ts
@@ -3,15 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { FileType, type Node } from '@nextcloud/files'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { n } from '@nextcloud/l10n'
/**
* Extract dir and name from file path
*
- * @param {string} path the full path
- * @return {string[]} [dirPath, fileName]
+ * @param path - The full path
+ * @return [dirPath, fileName]
*/
-export const extractFilePaths = function(path) {
+export function extractFilePaths(path: string): [string, string] {
const pathSections = path.split('/')
const fileName = pathSections[pathSections.length - 1]
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
@@ -20,32 +20,28 @@ export const extractFilePaths = function(path) {
/**
* Generate a translated summary of an array of nodes
- * @param {Node[]} nodes the nodes to summarize
- * @param {number} hidden the number of hidden nodes
- * @return {string}
+ *
+ * @param nodes - The nodes to summarize
+ * @param hidden - The number of hidden nodes
*/
-export const getSummaryFor = (nodes: Node[], hidden = 0): string => {
+export function getSummaryFor(nodes: Node[], hidden = 0): string {
const fileCount = nodes.filter(node => node.type === FileType.File).length
const folderCount = nodes.filter(node => node.type === FileType.Folder).length
- let summary = ''
-
- if (fileCount === 0) {
- summary = n('files', '{folderCount} folder', '{folderCount} folders', folderCount, { folderCount })
- } else if (folderCount === 0) {
- summary = n('files', '{fileCount} file', '{fileCount} files', fileCount, { fileCount })
- } else if (fileCount === 1) {
- summary = n('files', '1 file and {folderCount} folder', '1 file and {folderCount} folders', folderCount, { folderCount })
- } else if (folderCount === 1) {
- summary = n('files', '{fileCount} file and 1 folder', '{fileCount} files and 1 folder', fileCount, { fileCount })
- } else {
- summary = t('files', '{fileCount} files and {folderCount} folders', { fileCount, folderCount })
+ const summary: string[] = []
+ if (fileCount > 0 || folderCount === 0) {
+ const fileSummary = n('files', '%n file', '%n files', fileCount)
+ summary.push(fileSummary)
+ }
+ if (folderCount > 0) {
+ const folderSummary = n('files', '%n folder', '%n folders', folderCount)
+ summary.push(folderSummary)
}
-
if (hidden > 0) {
- // TRANSLATORS: This is a summary of files and folders, where {hiddenFilesAndFolders} is the number of hidden files and folders
- summary += ' ' + n('files', '(%n hidden)', ' (%n hidden)', hidden)
+ // TRANSLATORS: This is the number of hidden files or folders
+ const hiddenSummary = n('files', '%n hidden', '%n hidden', hidden)
+ summary.push(hiddenSummary)
}
- return summary
+ return summary.join(' · ')
}
diff --git a/apps/files/src/utils/filesViews.spec.ts b/apps/files/src/utils/filesViews.spec.ts
new file mode 100644
index 00000000000..e8c2ab3a6c1
--- /dev/null
+++ b/apps/files/src/utils/filesViews.spec.ts
@@ -0,0 +1,75 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, test } from 'vitest'
+import { defaultView, hasPersonalFilesView } from './filesViews.ts'
+
+describe('hasPersonalFilesView', () => {
+ beforeEach(() => removeInitialState())
+
+ test('enabled if user has unlimited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('enabled if user has limited quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 1234 })
+ expect(hasPersonalFilesView()).toBe(true)
+ })
+
+ test('disabled if user has no quota', () => {
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(hasPersonalFilesView()).toBe(false)
+ })
+})
+
+describe('defaultView', () => {
+ beforeEach(() => {
+ document.querySelectorAll('input[type="hidden"]').forEach((el) => {
+ el.remove()
+ })
+ })
+
+ test('Returns files view if set', () => {
+ mockInitialState('files', 'config', { default_view: 'files' })
+ expect(defaultView()).toBe('files')
+ })
+
+ test('Returns personal view if set and enabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: -1 })
+ expect(defaultView()).toBe('personal')
+ })
+
+ test('Falls back to files if personal view is disabled', () => {
+ mockInitialState('files', 'config', { default_view: 'personal' })
+ mockInitialState('files', 'storageStats', { quota: 0 })
+ expect(defaultView()).toBe('files')
+ })
+})
+
+/**
+ * Remove the mocked initial state
+ */
+function removeInitialState(): void {
+ document.querySelectorAll('input[type="hidden"]').forEach((el) => {
+ el.remove()
+ })
+}
+
+/**
+ * Helper to mock an initial state value
+ * @param app - The app
+ * @param key - The key
+ * @param value - The value
+ */
+function mockInitialState(app: string, key: string, value: unknown): void {
+ const el = document.createElement('input')
+ el.value = btoa(JSON.stringify(value))
+ el.id = `initial-state-${app}-${key}`
+ el.type = 'hidden'
+
+ document.head.appendChild(el)
+}
diff --git a/apps/files/src/utils/filesViews.ts b/apps/files/src/utils/filesViews.ts
new file mode 100644
index 00000000000..9489c35cbde
--- /dev/null
+++ b/apps/files/src/utils/filesViews.ts
@@ -0,0 +1,30 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { UserConfig } from '../types.ts'
+
+import { loadState } from '@nextcloud/initial-state'
+
+/**
+ * Check whether the personal files view can be shown
+ */
+export function hasPersonalFilesView(): boolean {
+ const storageStats = loadState('files', 'storageStats', { quota: -1 })
+ // Don't show this view if the user has no storage quota
+ return storageStats.quota !== 0
+}
+
+/**
+ * Get the default files view
+ */
+export function defaultView() {
+ const { default_view: defaultView } = loadState<Partial<UserConfig>>('files', 'config', { default_view: 'files' })
+
+ // the default view - only use the personal one if it is enabled
+ if (defaultView !== 'personal' || hasPersonalFilesView()) {
+ return defaultView
+ }
+ return 'files'
+}
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 872b8a8e6d3..1aee8d0ae79 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)">
@@ -24,17 +45,16 @@
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
</NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch data-cy-files-settings-setting="show_mime_column"
+ :checked="userConfig.show_mime_column"
+ @update:checked="setConfig('show_mime_column', $event)">
+ {{ t('files', 'Show file type column') }}
+ </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch data-cy-files-settings-setting="crop_image_previews"
:checked="userConfig.crop_image_previews"
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="enableGridView"
- data-cy-files-settings-setting="grid_view"
- :checked="userConfig.grid_view"
- @update:checked="setConfig('grid_view', $event)">
- {{ t('files', 'Enable the grid view') }}
- </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree"
:checked="userConfig.folder_tree"
@update:checked="setConfig('folder_tree', $event)">
@@ -375,6 +395,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 15286a220e9..cddacc863e1 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -57,7 +57,7 @@ import { translate as t } from '@nextcloud/l10n'
import { generateRemoteUrl } from '@nextcloud/router'
import { normalize, extname, join } from 'path'
import { defineComponent } from 'vue'
-import { createFromTemplate, getTemplates } from '../services/Templates.js'
+import { createFromTemplate, getTemplates, getTemplateFields } from '../services/Templates.js'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcModal from '@nextcloud/vue/components/NcModal'
@@ -215,7 +215,7 @@ export default defineComponent({
}
},
- async createFile(templateFields) {
+ async createFile(templateFields = []) {
const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/'
// If the file doesn't have an extension, add the default one
@@ -274,9 +274,18 @@ export default defineComponent({
},
async onSubmit() {
- if (this.selectedTemplate?.fields?.length > 0) {
+ const fileId = this.selectedTemplate?.fileid
+
+ // Only request field extraction if there is a valid template
+ // selected and it's not the blank template
+ let fields = []
+ if (fileId && fileId !== this.emptyTemplate.fileid) {
+ fields = await getTemplateFields(fileId)
+ }
+
+ if (fields.length > 0) {
spawnDialog(TemplateFiller, {
- fields: this.selectedTemplate.fields,
+ fields,
onSubmit: this.createFile,
})
} else {
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,
+ }))
+}