diff options
author | skjnldsv <skjnldsv@protonmail.com> | 2025-07-03 16:42:33 +0200 |
---|---|---|
committer | skjnldsv <skjnldsv@protonmail.com> | 2025-07-04 11:50:05 +0200 |
commit | 1dee5be1475d591c8dbb0f50346a4c647c5c39f8 (patch) | |
tree | 8e2859ad7d8e7247667dd9da0f92aca6f84f416e | |
parent | 3664761f875fa092ddfaa5f064e705c0cf6aeda8 (diff) | |
download | nextcloud-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.ts | 9 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 17 | ||||
-rw-r--r-- | apps/files/src/components/FilesNavigationItem.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/FilesNavigationSearch.vue | 6 | ||||
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 10 | ||||
-rw-r--r-- | apps/files/src/services/RecommendedFiles.ts | 5 | ||||
-rw-r--r-- | apps/files/src/services/Search.ts | 10 | ||||
-rw-r--r-- | apps/files/src/services/WebDavSearch.ts | 11 | ||||
-rw-r--r-- | apps/files/src/store/files.ts | 8 | ||||
-rw-r--r-- | apps/files/src/store/search.ts | 2 | ||||
-rw-r--r-- | apps/files/src/views/FilesHeaderHomeSearch.vue | 84 | ||||
-rw-r--r-- | apps/files/src/views/FilesList.vue | 127 | ||||
-rw-r--r-- | apps/files/src/views/home.scss | 30 | ||||
-rw-r--r-- | apps/files/src/views/home.ts | 155 | ||||
-rw-r--r-- | apps/files/src/views/recent.ts | 4 |
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.'), |