aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/components')
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue2
-rw-r--r--apps/files/src/components/DragAndDropPreview.vue22
-rw-r--r--apps/files/src/components/FileEntry/FavoriteIcon.vue4
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue56
-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/FilesListTableHeaderActions.vue5
-rw-r--r--apps/files/src/components/FilesListVirtual.vue137
-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/NewNodeDialog.vue13
-rw-r--r--apps/files/src/components/VirtualList.vue17
13 files changed, 372 insertions, 79 deletions
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 38d07c94d80..c7684d5c205 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -235,7 +235,7 @@ export default defineComponent({
justify-content: center;
width: 100%;
// Breadcrumbs height + row thead height
- min-height: calc(58px + 55px);
+ min-height: calc(58px + 44px);
margin: 0;
user-select: none;
color: var(--color-text-maxcontrast);
diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue
index 7c9c6f4f1a7..72fd98d43fb 100644
--- a/apps/files/src/components/DragAndDropPreview.vue
+++ b/apps/files/src/components/DragAndDropPreview.vue
@@ -92,7 +92,7 @@ export default Vue.extend({
</script>
<style lang="scss">
-$size: 32px;
+$size: 28px;
$stack-shift: 6px;
.files-list-drag-image {
@@ -102,24 +102,24 @@ $stack-shift: 6px;
display: flex;
overflow: hidden;
align-items: center;
- height: 44px;
- padding: 6px 12px;
+ height: $size + $stack-shift;
+ padding: $stack-shift $stack-shift * 2;
background: var(--color-main-background);
&__icon,
- .files-list__row-icon {
+ .files-list__row-icon-preview-container {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
- width: 32px;
- height: 32px;
+ width: $size - $stack-shift;
+ height: $size - $stack-shift;;
border-radius: var(--border-radius);
}
&__icon {
overflow: visible;
- margin-inline-end: 12px;
+ margin-inline-end: $stack-shift * 2;
img {
max-width: 100%;
@@ -138,13 +138,15 @@ $stack-shift: 6px;
display: flex;
// Stack effect if more than one element
- .files-list__row-icon + .files-list__row-icon {
+ // Max 3 elements
+ > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container {
margin-top: $stack-shift;
- margin-inline-start: $stack-shift - $size;
- & + .files-list__row-icon {
+ margin-inline-start: $stack-shift * 2 - $size;
+ & + .files-list__row-icon-preview-container {
margin-top: $stack-shift * 2;
}
}
+
// If we have manually clone the preview,
// let's hide any fallback icons
&:not(:empty) + * {
diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue
index 87684758b43..c66cb8fbd7f 100644
--- a/apps/files/src/components/FileEntry/FavoriteIcon.vue
+++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue
@@ -56,8 +56,8 @@ export default defineComponent({
:deep() {
svg {
// We added a stroke for a11y so we must increase the size to include the stroke
- width: 26px !important;
- height: 26px !important;
+ width: 20px !important;
+ height: 20px !important;
// Override NcIconSvgWrapper defaults of 20px
max-width: unset !important;
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index c130ab49c0a..5c537d878fe 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -25,15 +25,16 @@
:open="openedMenu"
@close="onMenuClose"
@closed="onMenuClosed">
- <!-- Default actions list-->
- <NcActionButton v-for="action, index in enabledMenuActions"
+ <!-- Non-destructive actions list -->
+ <!-- Please keep this block in sync with the destructive actions block below -->
+ <NcActionButton v-for="action, index in renderedNonDestructiveActions"
:key="action.id"
:ref="`action-${action.id}`"
class="files-list__row-action"
:class="{
[`files-list__row-action-${action.id}`]: true,
'files-list__row-action--inline': index < enabledInlineActions.length,
- 'files-list__row-action--menu': isValidMenu(action)
+ 'files-list__row-action--menu': isValidMenu(action),
}"
:close-after-click="!isValidMenu(action)"
:data-cy-files-list-row-action="action.id"
@@ -50,6 +51,35 @@
{{ actionDisplayName(action) }}
</NcActionButton>
+ <!-- Destructive actions list -->
+ <template v-if="renderedDestructiveActions.length > 0">
+ <NcActionSeparator />
+ <NcActionButton v-for="action, index in renderedDestructiveActions"
+ :key="action.id"
+ :ref="`action-${action.id}`"
+ class="files-list__row-action"
+ :class="{
+ [`files-list__row-action-${action.id}`]: true,
+ 'files-list__row-action--inline': index < enabledInlineActions.length,
+ 'files-list__row-action--menu': isValidMenu(action),
+ 'files-list__row-action--destructive': true,
+ }"
+ :close-after-click="!isValidMenu(action)"
+ :data-cy-files-list-row-action="action.id"
+ :is-menu="isValidMenu(action)"
+ :aria-label="action.title?.([source], currentView)"
+ :title="action.title?.([source], currentView)"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
+ <NcIconSvgWrapper v-else
+ class="files-list__row-action-icon"
+ :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ actionDisplayName(action) }}
+ </NcActionButton>
+ </template>
+
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
@@ -68,10 +98,11 @@
class="files-list__row-action--submenu"
close-after-click
:data-cy-files-list-row-action="action.id"
+ :aria-label="action.title?.([source], currentView)"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
- <NcLoadingIcon v-if="isLoadingAction(action)" :size="18" />
+ <NcLoadingIcon v-if="isLoadingAction(action)" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
@@ -211,6 +242,14 @@ export default defineComponent({
return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent)))
},
+ renderedNonDestructiveActions() {
+ return this.enabledMenuActions.filter(action => !action.destructive)
+ },
+
+ renderedDestructiveActions() {
+ return this.enabledMenuActions.filter(action => action.destructive)
+ },
+
openedMenu: {
get() {
return this.opened
@@ -281,7 +320,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)
@@ -349,5 +388,12 @@ main.app-content[style*="mouse-pos-x"] .v-popper__popper {
max-height: var(--max-icon-size) !important;
max-width: var(--max-icon-size) !important;
}
+
+ &.files-list__row-action--destructive {
+ ::deep(button) {
+ color: var(--color-error) !important;
+ }
+ }
}
+
</style>
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/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 8fdc87b154c..fbf614caede 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -21,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>
@@ -46,6 +48,11 @@
:nodes="nodes" />
</template>
+ <!-- Body replacement if no files are available -->
+ <template #empty>
+ <slot name="empty" />
+ </template>
+
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter :current-view="currentView"
@@ -63,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'
@@ -79,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'
@@ -88,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',
@@ -140,6 +146,7 @@ export default defineComponent({
selectionStore,
userConfigStore,
+ n,
t,
}
},
@@ -149,7 +156,6 @@ export default defineComponent({
FileEntry,
FileEntryGrid,
scrollToIndex: 0,
- openFileId: null as number|null,
}
},
@@ -214,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()
},
},
@@ -276,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.
@@ -285,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) {
@@ -301,6 +321,7 @@ export default defineComponent({
}
this.scrollToIndex = Math.max(0, index)
+ logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
@@ -312,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 ?? '') },
@@ -365,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) {
@@ -446,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(
@@ -462,15 +481,17 @@ export default defineComponent({
<style scoped lang="scss">
.files-list {
- --row-height: 55px;
+ --row-height: 44px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: var(--default-clickable-area);
- --icon-preview-size: 32px;
+ --icon-preview-size: 24px;
--fixed-block-start-position: var(--default-clickable-area);
+ display: flex;
+ flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
@@ -518,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 {
@@ -549,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,
@@ -567,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;
@@ -736,8 +775,8 @@ export default defineComponent({
// File and folder overlay
&-overlay {
position: absolute;
- max-height: calc(var(--icon-preview-size) * 0.5);
- max-width: calc(var(--icon-preview-size) * 0.5);
+ max-height: calc(var(--icon-preview-size) * 0.6);
+ max-width: calc(var(--icon-preview-size) * 0.6);
color: var(--color-primary-element-text);
// better alignment with the folder icon
margin-block-start: 2px;
@@ -855,7 +894,7 @@ export default defineComponent({
}
.files-list__row-mtime {
- width: calc(var(--row-height) * 2);
+ width: calc(var(--row-height) * 2.5);
}
.files-list__row-mime {
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/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
index 76555db1536..ca10935940d 100644
--- a/apps/files/src/components/NewNodeDialog.vue
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -26,6 +26,11 @@
:helper-text="validity"
:label="label"
:value.sync="localDefaultName" />
+
+ <!-- Hidden file warning -->
+ <NcNoteCard v-if="isHiddenFileName"
+ type="warning"
+ :text="t('files', 'Files starting with a dot are hidden by default')" />
</form>
</NcDialog>
</template>
@@ -35,12 +40,13 @@ import type { ComponentPublicInstance, PropType } from 'vue'
import { getUniqueName } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { extname } from 'path'
-import { nextTick, onMounted, ref, watch, watchEffect } from 'vue'
+import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import { getFilenameValidity } from '../utils/filenameValidity.ts'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
const props = defineProps({
/**
@@ -89,6 +95,11 @@ const nameInput = ref<ComponentPublicInstance>()
const formElement = ref<HTMLFormElement>()
const validity = ref('')
+const isHiddenFileName = computed(() => {
+ // Check if the name starts with a dot, which indicates a hidden file
+ return localDefaultName.value.trim().startsWith('.')
+})
+
/**
* Focus the filename input field
*/
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 5ae8220d594..4746fedf863 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 }}
@@ -146,7 +157,7 @@ export default defineComponent({
itemHeight() {
// Align with css in FilesListVirtual
// 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom)
- return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 55
+ return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44
},
// Grid mode only
@@ -309,7 +320,7 @@ export default defineComponent({
methods: {
scrollTo(index: number) {
- if (!this.$el) {
+ if (!this.$el || this.index === index) {
return
}