aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2025-07-03 16:42:33 +0200
committerskjnldsv <skjnldsv@protonmail.com>2025-07-04 11:50:05 +0200
commit1dee5be1475d591c8dbb0f50346a4c647c5c39f8 (patch)
tree8e2859ad7d8e7247667dd9da0f92aca6f84f416e
parent3664761f875fa092ddfaa5f064e705c0cf6aeda8 (diff)
downloadnextcloud-server-feat/files-home-view.tar.gz
nextcloud-server-feat/files-home-view.zip
fixup! fixup! feat(files): add Home viewfeat/files-home-view
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
-rw-r--r--apps/files/src/actions/openInFilesAction.ts9
-rw-r--r--apps/files/src/components/FilesListVirtual.vue17
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue2
-rw-r--r--apps/files/src/components/FilesNavigationSearch.vue6
-rw-r--r--apps/files/src/components/VirtualList.vue10
-rw-r--r--apps/files/src/services/RecommendedFiles.ts5
-rw-r--r--apps/files/src/services/Search.ts10
-rw-r--r--apps/files/src/services/WebDavSearch.ts11
-rw-r--r--apps/files/src/store/files.ts8
-rw-r--r--apps/files/src/store/search.ts2
-rw-r--r--apps/files/src/views/FilesHeaderHomeSearch.vue84
-rw-r--r--apps/files/src/views/FilesList.vue127
-rw-r--r--apps/files/src/views/home.scss30
-rw-r--r--apps/files/src/views/home.ts155
-rw-r--r--apps/files/src/views/recent.ts4
15 files changed, 322 insertions, 158 deletions
diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts
index 741e37bd7c7..e6feeab6051 100644
--- a/apps/files/src/actions/openInFilesAction.ts
+++ b/apps/files/src/actions/openInFilesAction.ts
@@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { Node } from '@nextcloud/files'
+import type { Node, View } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { FileType, FileAction, DefaultType } from '@nextcloud/files'
+import { VIEW_ID as HOME_VIEW_ID } from '../views/home'
+import { VIEW_ID as RECENT_VIEW_ID } from '../views/recent'
import { VIEW_ID as SEARCH_VIEW_ID } from '../views/search'
export const action = new FileAction({
@@ -14,9 +16,8 @@ export const action = new FileAction({
displayName: () => t('files', 'Open in Files'),
iconSvgInline: () => '',
- enabled: (nodes, view: View) => [
- 'home',
- 'recent',
+ enabled: (nodes: Node[], view: View) => [
+ RECENT_VIEW_ID,
SEARCH_VIEW_ID,
].includes(view.id),
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index feb4b61c53e..8834bff81d7 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -48,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"
@@ -474,6 +479,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;
@@ -570,6 +577,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;
diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue
index 2c7c8b4b944..519ca8547e1 100644
--- a/apps/files/src/components/FilesNavigationItem.vue
+++ b/apps/files/src/components/FilesNavigationItem.vue
@@ -104,7 +104,7 @@ export default defineComponent({
methods: {
filterVisible(views: View[]) {
- return views.filter(({ _view, id }) => id === this.currentView?.id || _view.hidden !== true)
+ return views.filter(({ hidden, id }) => id === this.currentView?.id || hidden !== true)
},
hasChildViews(view: View): boolean {
diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue
index 85dc5534e5e..4903ed950e3 100644
--- a/apps/files/src/components/FilesNavigationSearch.vue
+++ b/apps/files/src/components/FilesNavigationSearch.vue
@@ -53,7 +53,7 @@ onBeforeNavigation((to, from, 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)
+const isSearchView = computed(() => currentView.value?.id === VIEW_ID)
/**
* Local search is only possible on real DAV resources within the files root
@@ -63,7 +63,7 @@ const canSearchLocally = computed(() => {
return true
}
- const folder = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
+ const folder = filesStore.getDirectoryByPath(currentView.value?.id, directory.value)
return folder?.isDavResource && folder?.root?.startsWith('/files/')
})
@@ -84,7 +84,7 @@ const searchLabel = computed(() => {
* @param value - The new value
*/
function onUpdateSearch(value: string) {
- if (searchStore.scope === 'locally' && currentView.value.id !== VIEW_ID) {
+ if (searchStore.scope === 'locally' && currentView.value?.id !== VIEW_ID) {
searchStore.base = filesStore.getDirectoryByPath(currentView.value.id, directory.value)
}
searchStore.query = value
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 5ae8220d594..c03865f6312 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -20,7 +20,14 @@
<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 v-else
+ class="files-list__table"
+ :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }">
<!-- Accessibility table caption for screen readers -->
<caption v-if="caption" class="hidden-visually">
{{ caption }}
@@ -62,6 +69,7 @@ import debounce from 'debounce'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import logger from '../logger.ts'
+import { data } from 'jquery'
interface RecycledPoolItem {
key: string,
diff --git a/apps/files/src/services/RecommendedFiles.ts b/apps/files/src/services/RecommendedFiles.ts
index f76bdda8200..30c28a83196 100644
--- a/apps/files/src/services/RecommendedFiles.ts
+++ b/apps/files/src/services/RecommendedFiles.ts
@@ -20,6 +20,7 @@ const isRecommendationEnabled = getCapabilities()?.recommendations?.enabled ===
if (isRecommendationEnabled) {
registerDavProperty('nc:recommendation-reason', { nc: 'http://nextcloud.org/ns' })
registerDavProperty('nc:recommendation-reason-label', { nc: 'http://nextcloud.org/ns' })
+ registerDavProperty('nc:recommendation-original-location', { nc: 'http://nextcloud.org/ns' })
}
export const getContents = (): CancelablePromise<ContentsWithRoot> => {
@@ -54,7 +55,9 @@ export const getContents = (): CancelablePromise<ContentsWithRoot> => {
}),
contents: contents.map((result) => {
try {
- return resultToNode(result, root)
+ // Force the sources to be in the user's root context
+ result.filename = `/files/${getCurrentUser()?.uid}` + result?.props?.['recommendation-original-location']
+ return resultToNode(result, `/files/${getCurrentUser()?.uid}`)
} catch (error) {
logger.error(`Invalid node detected '${result.basename}'`, { error })
return null
diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts
index ae6f1ee50e0..8b2d86c0bea 100644
--- a/apps/files/src/services/Search.ts
+++ b/apps/files/src/services/Search.ts
@@ -17,7 +17,7 @@ import { getPinia } from '../store/index.ts'
/**
* Get the contents for a search view
*/
-export function getContents(): CancelablePromise<ContentsWithRoot> {
+export function getContents(query = ''): CancelablePromise<ContentsWithRoot> {
const controller = new AbortController()
const searchStore = useSearchStore(getPinia())
@@ -26,7 +26,7 @@ export function getContents(): CancelablePromise<ContentsWithRoot> {
return new CancelablePromise<ContentsWithRoot>(async (resolve, reject, cancel) => {
cancel(() => controller.abort())
try {
- const contents = await searchNodes(searchStore.query, { dir, signal: controller.signal })
+ const contents = await searchNodes(query || searchStore.query, { dir, signal: controller.signal })
resolve({
contents,
folder: new Folder({
@@ -37,6 +37,12 @@ export function getContents(): CancelablePromise<ContentsWithRoot> {
}),
})
} catch (error) {
+ // Be silent if the request was canceled
+ if (error?.name === 'AbortError') {
+ logger.debug('Search request was canceled', { query, dir })
+ reject(error)
+ return
+ }
logger.error('Failed to fetch search results', { error })
reject(error)
}
diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts
index feb7f30b357..7cfb0379650 100644
--- a/apps/files/src/services/WebDavSearch.ts
+++ b/apps/files/src/services/WebDavSearch.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { INode } from '@nextcloud/files'
+import type { Node } from '@nextcloud/files'
import type { ResponseDataDetailed, SearchResult } from 'webdav'
import { getCurrentUser } from '@nextcloud/auth'
@@ -17,6 +17,8 @@ export interface SearchNodesOptions {
signal?: AbortSignal
}
+export const MIN_SEARCH_LENGTH = 3
+
/**
* Search for nodes matching the given query.
*
@@ -25,16 +27,18 @@ export interface SearchNodesOptions {
* @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[]> {
+export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise<Node[]> {
const user = getCurrentUser()
if (!user) {
// the search plugin only works for user roots
+ logger.debug('No user found for search', { query, dir })
return []
}
query = query.trim()
- if (query.length < 3) {
+ if (query.length < MIN_SEARCH_LENGTH) {
// the search plugin only works with queries of at least 3 characters
+ logger.debug('Search query too short', { query })
return []
}
@@ -75,6 +79,7 @@ export async function searchNodes(query: string, { dir, signal }: SearchNodesOpt
// check if the request was aborted
if (signal?.aborted) {
+ logger.debug('Search request aborted', { query, dir })
return []
}
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index 3591832d0c4..1bf1b11c1fb 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -96,7 +96,7 @@ export const useFilesStore = function(...args) {
updateNodes(nodes: Node[]) {
// Update the store all at once
const files = nodes.reduce((acc, node) => {
- if (!node.fileid) {
+ if (typeof node.fileid !== 'number') {
logger.error('Trying to update/set a node without fileid', { node })
return acc
}
@@ -129,7 +129,7 @@ export const useFilesStore = function(...args) {
},
onMovedNode({ node, oldSource }: { node: Node, oldSource: string }) {
- if (!node.fileid) {
+ if (typeof node.fileid !== 'number') {
logger.error('Trying to update/set a node without fileid', { node })
return
}
@@ -140,7 +140,7 @@ export const useFilesStore = function(...args) {
},
async onUpdatedNode(node: Node) {
- if (!node.fileid) {
+ if (typeof node.fileid !== 'number') {
logger.error('Trying to update/set a node without fileid', { node })
return
}
@@ -154,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 (node?.source === nodes[0]?.source) {
this.updateNodes([node])
return
}
diff --git a/apps/files/src/store/search.ts b/apps/files/src/store/search.ts
index 286cad253fc..6e9b4632dba 100644
--- a/apps/files/src/store/search.ts
+++ b/apps/files/src/store/search.ts
@@ -29,7 +29,7 @@ export const useSearchStore = defineStore('search', () => {
* Scope of the search.
* Scopes:
* - filter: only filter current file list
- * - locally: search from current location recursivly
+ * - locally: search from current location recursively
* - globally: search everywhere
*/
const scope = ref<SearchScope>('filter')
diff --git a/apps/files/src/views/FilesHeaderHomeSearch.vue b/apps/files/src/views/FilesHeaderHomeSearch.vue
index 431a6f7b4e9..01d70376858 100644
--- a/apps/files/src/views/FilesHeaderHomeSearch.vue
+++ b/apps/files/src/views/FilesHeaderHomeSearch.vue
@@ -5,15 +5,18 @@
<template>
<div class="files-list__header-home-search-wrapper">
- <NcTextField v-model="searchText"
+ <NcTextField ref="searchInput"
+ :value="searchText"
class="files-list__header-home-search-input"
:label="t('files', 'Search files and folders')"
+ minlength="3"
:pill="true"
trailing-button-icon="close"
:trailing-button-label="t('files', 'Clear search')"
:show-trailing-button="searchText.trim() !== ''"
type="search"
- @trailing-button-click="searchText = ''">
+ @trailing-button-click="emit('update:searchText', '')"
+ @update:model-value="onSearch">
<template #icon>
<Magnify :size="20" />
</template>
@@ -21,57 +24,52 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue'
+<script setup lang="ts">
+import type { View } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
+import { defineProps, withDefaults, defineEmits, ref, onMounted } from 'vue'
+import { subscribe } from '@nextcloud/event-bus'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import Magnify from 'vue-material-design-icons/Magnify.vue'
-export default defineComponent({
- name: 'FilesHeaderHomeSearch',
+import { VIEW_ID } from './search'
+import logger from '../logger'
- components: {
- NcTextField,
- Magnify,
- },
+interface Props {
+ searchText?: string
+}
- setup() {
- return {
- t,
- }
- },
+withDefaults(defineProps<Props>(), {
+ searchText: '',
+})
- data() {
- return {
- searchText: '',
- }
- },
+const emit = defineEmits<{
+ (e: 'update:searchText', query: string): void
+}>()
- methods: {
- },
-})
-</script>
+const searchInput = ref(null) as NcTextField
+const onSearch = (text: string) => {
+ const input = searchInput?.value?.$refs?.inputField?.$refs?.input as HTMLInputElement
+ input?.reportValidity?.()
-<style lang="scss">
-// Align everything in the middle
-.files-list__header-home-search-wrapper,
-.files-content__home .files-list__filters {
- display: flex !important;
- max-width: var(--breakpoint-mobile) !important;
- height: auto !important;
- margin: 0 auto !important;
- padding-inline: calc(var(--clickable-area-small, 24px) / 2) !important;
+ // Emit the search text to the parent component
+ emit('update:searchText', text)
}
-.files-list__header-home-search-wrapper {
- // global default is 34px, but we want to have a bigger clickable area
- --default-clickable-area: var(---clickable-area-large, 48px);
- justify-content: center;
-}
+onMounted(() => {
+ const input = searchInput?.value?.$refs?.inputField?.$refs?.input as HTMLInputElement
+ input?.focus?.()
+})
-// Align the filters with the search input for the Home view
-.files-content__home .files-list__filters {
- padding-block: calc(var(--default-grid-baseline, 4px) * 2) !important;
-}
-</style>
+// Subscribing here to ensure we have mounted already and all views are registered
+subscribe('files:navigation:changed', (view: View) => {
+ if (view.id !== VIEW_ID) {
+ return
+ }
+
+ // Reset search text when navigating away
+ logger.info('Resetting search on navigation away from home view')
+ emit('update:searchText', '')
+})
+</script>
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index e5a28d97e4a..ae3578988e9 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -76,75 +76,71 @@
<DragAndDropNotice v-if="!loading && canUpload && currentFolder" :current-folder="currentFolder" />
<!-- Initial loading -->
- <NcLoadingIcon v-if="loading && !isRefreshing"
+ <NcLoadingIcon v-if="!currentView || !currentFolder"
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 -->
<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>
@@ -343,7 +339,7 @@ export default defineComponent({
/**
* The current directory contents.
*/
- dirContentsSorted() {
+ dirContentsSorted(): Node[] {
if (!this.currentView) {
return []
}
@@ -579,9 +575,20 @@ export default defineComponent({
if (!currentView) {
logger.debug('The current view doesn\'t 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 home view
+ window.addEventListener('DOMContentLoaded', () => {
+ if (!this.currentView) {
+ logger.warn('No current view after DOMContentLoaded, redirecting to home view')
+ window.OCP.Files.Router.goToRoute(null, { view: 'home' })
+ }
+ }, { 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/home.scss b/apps/files/src/views/home.scss
new file mode 100644
index 00000000000..b1e2e96c188
--- /dev/null
+++ b/apps/files/src/views/home.scss
@@ -0,0 +1,30 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// Align everything in the middle
+.files-list__header-home-search-wrapper,
+.files-content[class*=files-content__home] .files-list__filters {
+ display: flex !important;
+ max-width: var(--breakpoint-mobile) !important;
+ height: auto !important;
+ margin: 0 auto !important;
+ padding-inline: calc(var(--clickable-area-small, 24px) / 2) !important;
+}
+
+.files-list__header-home-search-wrapper {
+ // global default is 34px, but we want to have a bigger clickable area
+ --default-clickable-area: var(---clickable-area-large, 48px);
+ justify-content: center;
+}
+
+// Align the filters with the search input for the Home view
+.files-content[class*=files-content__home] .files-list__filters {
+ padding-block: calc(var(--default-grid-baseline, 4px) * 2) !important;
+}
+
+// Wider recommendations reason label column
+.files-list__row-home-recommendation-reason {
+ width: calc(var(--row-height) * 2.5) !important;
+} \ No newline at end of file
diff --git a/apps/files/src/views/home.ts b/apps/files/src/views/home.ts
index 6dcc479d16e..1940734cef3 100644
--- a/apps/files/src/views/home.ts
+++ b/apps/files/src/views/home.ts
@@ -1,19 +1,59 @@
/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { VueConstructor } from 'vue'
-import { getCanonicalLocale, getLanguage, translate as t } from '@nextcloud/l10n'
-import HomeSvg from '@mdi/svg/svg/home.svg?raw'
+import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component-public-instance'
+import type RouterService from '../services/RouterService'
-import { getContents } from '../services/RecommendedFiles'
import { Column, Folder, getNavigation, Header, registerFileListHeaders, View } from '@nextcloud/files'
+import { emit } from '@nextcloud/event-bus'
+import { getCanonicalLocale, getLanguage, t } from '@nextcloud/l10n'
+import debounce from 'debounce'
import Vue from 'vue'
+import HomeSvg from '@mdi/svg/svg/home.svg?raw'
+import MagnifySvg from '@mdi/svg/svg/magnify.svg?raw'
+
+import { getContents } from '../services/RecommendedFiles'
+import { getContents as getSearchContents } from '../services/Search'
+import { MIN_SEARCH_LENGTH } from '../services/WebDavSearch'
+import logger from '../logger'
+import './home.scss'
+
+let searchText = ''
+let FilesHeaderHomeSearchInstance: Vue
+let FilesHeaderHomeSearchView: ComponentPublicInstanceConstructor
+
+export const VIEW_ID = 'home'
+export const VIEW_ID_SEARCH = VIEW_ID + '-search'
+
+const recommendationReasonColumn = new Column({
+ id: 'recommendation-reason',
+ title: t('files', 'Reason'),
+ sort(a, b) {
+ const aReason = a.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
+ const bReason = b.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
+ return aReason.localeCompare(bReason, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' })
+ },
+ render(node) {
+ const reason = node.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
+ const span = document.createElement('span')
+ span.textContent = reason
+ return span
+ },
+})
+
export const registerHomeView = () => {
+ // If we have a search query in the URL, use it
+ const currentUrl = new URL(window.location.href)
+ const searchQuery = currentUrl.searchParams.get('query')
+ if (searchQuery) {
+ searchText = searchQuery.trim()
+ }
+
const Navigation = getNavigation()
- Navigation.register(new View({
- id: 'home',
+ const HomeView = new View({
+ id: VIEW_ID,
name: t('files', 'Home'),
caption: t('files', 'Files home view'),
icon: HomeSvg,
@@ -21,42 +61,89 @@ export const registerHomeView = () => {
defaultSortKey: 'mtime',
- getContents,
+ getContents: () => (searchText && searchText.length >= MIN_SEARCH_LENGTH)
+ ? getSearchContents(searchText)
+ : getContents(),
- columns: [
- new Column({
- id: 'recommendation-reason',
- title: t('files', 'Reason'),
- sort(a, b) {
- const aReason = a.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
- const bReason = b.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
- return aReason.localeCompare(bReason, [getLanguage(), getCanonicalLocale()], { numeric: true, usage: 'sort' })
- },
- render(node) {
- const reason = node.attributes?.['recommendation-reason-label'] || t('files', 'Suggestion')
- const span = document.createElement('span')
- span.textContent = reason
- return span
- },
- }),
- ],
- }))
+ columns: [recommendationReasonColumn],
+ })
+ Navigation.register(HomeView)
- let FilesHeaderHomeSearch: VueConstructor
registerFileListHeaders(new Header({
- id: 'home-search',
+ id: 'files-header-home-search',
order: 0,
// Always enabled for the home view
- enabled: (folder: Folder, view: View) => view.id === 'home',
+ enabled: (folder: Folder, view: View) => view.id === VIEW_ID,
// It's pretty static, so no need to update
updated: () => {},
// render simply spawns the component
- render: async (el: HTMLElement) => {
- if (FilesHeaderHomeSearch === undefined) {
- const { default: component } = await import('../views/FilesHeaderHomeSearch.vue')
- FilesHeaderHomeSearch = Vue.extend(component)
+ render: async (el: HTMLElement, folder: Folder) => {
+ // If the search component is already mounted, destroy it
+ if (!FilesHeaderHomeSearchView) {
+ FilesHeaderHomeSearchView = (await import('./FilesHeaderHomeSearch.vue')).default
+ } else {
+ FilesHeaderHomeSearchInstance.$destroy()
+ logger.debug('Destroying existing FilesHeaderHomeSearchInstance', { searchText })
}
- new FilesHeaderHomeSearch().$mount(el)
+
+ // Create a new instance of the search component
+ FilesHeaderHomeSearchInstance = new Vue({
+ extends: FilesHeaderHomeSearchView,
+ propsData: {
+ searchText,
+ },
+ }).$on('update:searchText', async (text: string) => {
+ updateSearchUrlQuery(text)
+ updateContent(folder)
+ }).$mount(el)
},
}))
+
+ /**
+ * Debounce and trigger the search/content update
+ * We only update the search context after the debounce
+ * to not display wrong messages before the search is completed.
+ */
+ const updateContent = debounce((folder: Folder) => {
+ emit('files:node:updated', folder)
+ updateHomeSearchContext()
+ }, 200)
+
+ /**
+ * Update the search URL query and the router
+ * @param query - The search query to set in the URL
+ */
+ const updateSearchUrlQuery = (query = '') => {
+ searchText = query.trim()
+ const router = window.OCP.Files.Router as RouterService
+ router.goToRoute(
+ router.name || undefined, // use default route
+ { ...router.params, view: VIEW_ID },
+ { ...router.query, ...{ query: searchText || undefined } },
+ )
+ }
+
+ /**
+ * Update the home view context based on
+ * the current search text
+ */
+ const updateHomeSearchContext = () => {
+ // Update caption if we have a search text
+ const isSearching = searchText && searchText.length >= MIN_SEARCH_LENGTH
+ HomeView.update({
+ caption: isSearching
+ ? t('files', 'Search results for "{searchText}"', { searchText })
+ : t('files', 'Files home view'),
+ icon: isSearching ? MagnifySvg : HomeSvg,
+ columns: isSearching ? [] : [recommendationReasonColumn],
+
+ emptyTitle: isSearching
+ ? t('files', 'No results found for "{searchText}"', { searchText })
+ : t('files', 'No recommendations'),
+ emptyCaption: isSearching
+ ? t('files', 'No results found for "{searchText}"', { searchText })
+ : t('files', 'No recommended files found'),
+ })
+ }
+ updateHomeSearchContext()
}
diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts
index d1b4b99b043..e64bc465bbc 100644
--- a/apps/files/src/views/recent.ts
+++ b/apps/files/src/views/recent.ts
@@ -8,10 +8,12 @@ import HistorySvg from '@mdi/svg/svg/history.svg?raw'
import { getContents } from '../services/Recent'
+export const VIEW_ID = 'recent'
+
export const registerRecentView = () => {
const Navigation = getNavigation()
Navigation.register(new View({
- id: 'recent',
+ id: VIEW_ID,
name: t('files', 'Recent'),
caption: t('files', 'List of recently modified files and folders.'),