From cde3dbc50efca15c44828af4feedfb46ba09e131 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Thu, 26 Jun 2025 14:05:54 +0200 Subject: [PATCH 1/3] feat(files): add Home view Signed-off-by: skjnldsv --- .../src/composables/useFileListHeaders.ts | 2 + apps/files/src/init.ts | 27 ++++--- apps/files/src/services/LivePhotos.ts | 8 +- apps/files/src/services/RecommendedFiles.ts | 80 +++++++++++++++++++ apps/files/src/services/ServiceWorker.js | 2 +- .../files/src/views/FilesHeaderHomeSearch.vue | 77 ++++++++++++++++++ apps/files/src/views/files.ts | 5 +- apps/files/src/views/home.ts | 42 ++++++++++ apps/files/src/views/personal-files.ts | 2 +- apps/files/src/views/recent.ts | 2 +- 10 files changed, 224 insertions(+), 23 deletions(-) create mode 100644 apps/files/src/services/RecommendedFiles.ts create mode 100644 apps/files/src/views/FilesHeaderHomeSearch.vue create mode 100644 apps/files/src/views/home.ts diff --git a/apps/files/src/composables/useFileListHeaders.ts b/apps/files/src/composables/useFileListHeaders.ts index b57bcbb143280..2e4e70a525fc1 100644 --- a/apps/files/src/composables/useFileListHeaders.ts +++ b/apps/files/src/composables/useFileListHeaders.ts @@ -15,5 +15,7 @@ export function useFileListHeaders(): ComputedRef { const headers = ref(getFileListHeaders()) const sorted = computed(() => [...headers.value].sort((a, b) => a.order - b.order) as Header[]) + console.debug('useFileListHeaders', { headers: sorted.value }) + return sorted } diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index a9aedb5fb6334..8684a57933249 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -2,7 +2,9 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { addNewFileMenuEntry, registerDavProperty, registerFileAction } from '@nextcloud/files' +import { addNewFileMenuEntry, registerFileAction } from '@nextcloud/files' +import { registerDavProperty } from '@nextcloud/files/dav' +import { isPublicShare } from '@nextcloud/sharing/public' import { action as deleteAction } from './actions/deleteAction' import { action as downloadAction } from './actions/downloadAction' @@ -14,28 +16,27 @@ import { action as openInFilesAction } from './actions/openInFilesAction' import { action as renameAction } from './actions/renameAction' import { action as sidebarAction } from './actions/sidebarAction' import { action as viewInFolderAction } from './actions/viewInFolderAction' +import { registerConvertActions } from './actions/convertAction.ts' import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts' import { registerTypeFilter } from './filters/TypeFilter.ts' import { registerModifiedFilter } from './filters/ModifiedFilter.ts' +import { registerFilenameFilter } from './filters/FilenameFilter.ts' import { entry as newFolderEntry } from './newMenu/newFolder.ts' import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts' 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 { registerFolderTreeView } from './views/folderTree.ts' +import { registerHomeView } from './views/home' +import { registerPersonalFilesView } from './views/personal-files' +import { registerRecentView } from './views/recent' 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 { registerLivePhotosService } from './services/LivePhotos' +import { registerPreviewServiceWorker } from './services/ServiceWorker.js' // Register file actions registerConvertActions() @@ -59,10 +60,11 @@ registerTemplateEntries() if (isPublicShare() === false) { registerFavoritesView() registerFilesView() + registerFolderTreeView() + registerHomeView() registerPersonalFilesView() registerRecentView() registerSearchView() - registerFolderTreeView() } // Register file list filters @@ -71,11 +73,10 @@ registerTypeFilter() registerModifiedFilter() registerFilenameFilter() -// Register preview service worker +// Register various services registerPreviewServiceWorker() +registerLivePhotosService() registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('nc:metadata-blurhash', { nc: 'http://nextcloud.org/ns' }) - -initLivePhotos() diff --git a/apps/files/src/services/LivePhotos.ts b/apps/files/src/services/LivePhotos.ts index 10be42444e20f..1533b154c9bd3 100644 --- a/apps/files/src/services/LivePhotos.ts +++ b/apps/files/src/services/LivePhotos.ts @@ -2,12 +2,14 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Node, registerDavProperty } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' +import { registerDavProperty } from '@nextcloud/files/dav' /** - * + * Registers the Live Photos service by adding a DAV property for live photos metadata. + * This allows the Nextcloud Files app to recognize and handle live photos. */ -export function initLivePhotos(): void { +export function registerLivePhotosService(): void { registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' }) } diff --git a/apps/files/src/services/RecommendedFiles.ts b/apps/files/src/services/RecommendedFiles.ts new file mode 100644 index 0000000000000..6c81d83bc5811 --- /dev/null +++ b/apps/files/src/services/RecommendedFiles.ts @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ContentsWithRoot } from '@nextcloud/files' + +import { CancelablePromise } from 'cancelable-promise' +import { File, Folder, Permission, } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' +import axios from '@nextcloud/axios' + +import { getContents as getDefaultContents } from './Files' + +type RecommendedFiles = { + 'id': string + 'timestamp': number + 'name': string + 'directory': string + 'extension': string + 'mimeType': string + 'hasPreview': boolean + 'reason': string +} + +type RecommendedFilesResponse = { + 'recommendations': RecommendedFiles[] +} + +const fetchRecommendedFiles = (controller: AbortController): Promise => { + const url = generateOcsUrl('apps/recommendations/api/v1/recommendations/always') + + return axios.get(url, { + signal: controller.signal, + headers: { + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json', + }, + }).then(resp => resp.data.ocs.data as RecommendedFilesResponse) +} + +export const getContents = (path = '/'): CancelablePromise => { + if (path !== '/') { + return getDefaultContents(path) + } + + const controller = new AbortController() + return new CancelablePromise(async (resolve, reject, cancel) => { + cancel(() => controller.abort()) + try { + const { recommendations } = await fetchRecommendedFiles(controller) + + resolve({ + folder: new Folder({ + id: 0, + source: `${getRemoteURL()}${getRootPath()}`, + root: getRootPath(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.READ, + }), + contents: recommendations.map((rec) => { + const Node = rec.mimeType === 'httpd/unix-directory' ? Folder : File + return new Node({ + id: parseInt(rec.id), + source: `${getRemoteURL()}/${getRootPath()}/${rec.directory}/${rec.name}`.replace(/\/\//g, '/'), + root: getRootPath(), + mime: rec.mimeType, + mtime: new Date(rec.timestamp * 1000), + owner: getCurrentUser()?.uid || null, + permissions: Permission.READ, + attributes: rec, + }) + }), + }) + } catch (error) { + reject(error) + } + }) +} diff --git a/apps/files/src/services/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js index cc13db440091e..7721112d64bc7 100644 --- a/apps/files/src/services/ServiceWorker.js +++ b/apps/files/src/services/ServiceWorker.js @@ -5,7 +5,7 @@ import { generateUrl, getRootUrl } from '@nextcloud/router' import logger from '../logger.ts' -export default () => { +export const registerPreviewServiceWorker = () => { if ('serviceWorker' in navigator) { // Use the window load event to keep the page load performant window.addEventListener('load', async () => { diff --git a/apps/files/src/views/FilesHeaderHomeSearch.vue b/apps/files/src/views/FilesHeaderHomeSearch.vue new file mode 100644 index 0000000000000..39bd9ce1dbbc6 --- /dev/null +++ b/apps/files/src/views/FilesHeaderHomeSearch.vue @@ -0,0 +1,77 @@ + + + + + + + diff --git a/apps/files/src/views/files.ts b/apps/files/src/views/files.ts index 699e173de6352..6045f091a66a9 100644 --- a/apps/files/src/views/files.ts +++ b/apps/files/src/views/files.ts @@ -10,10 +10,7 @@ import { View, getNavigation } from '@nextcloud/files' export const VIEW_ID = 'files' -/** - * Register the files view to the navigation - */ -export function registerFilesView() { +export const registerFilesView = () => { const Navigation = getNavigation() Navigation.register(new View({ id: VIEW_ID, diff --git a/apps/files/src/views/home.ts b/apps/files/src/views/home.ts new file mode 100644 index 0000000000000..ddc70561f7279 --- /dev/null +++ b/apps/files/src/views/home.ts @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ComponentPublicInstance, VueConstructor } from 'vue' +import { translate as t } from '@nextcloud/l10n' +import HomeSvg from '@mdi/svg/svg/home.svg?raw' + +import { getContents } from '../services/RecommendedFiles' +import { Folder, getNavigation, Header, registerFileListHeaders, View } from '@nextcloud/files' +import Vue from 'vue' + +export const registerHomeView = () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: 'home', + name: t('files', 'Home'), + caption: t('files', 'Files home view'), + icon: HomeSvg, + order: -50, + + getContents, + })) + + let FilesHeaderHomeSearch: VueConstructor + registerFileListHeaders(new Header({ + id: 'home-search', + order: 0, + // Always enabled for the home view + enabled: (folder: Folder, view: View) => view.id === 'home', + // 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) + } + new FilesHeaderHomeSearch().$mount(el) + }, + })) +} diff --git a/apps/files/src/views/personal-files.ts b/apps/files/src/views/personal-files.ts index 66d4e77b3765e..78225995beb3b 100644 --- a/apps/files/src/views/personal-files.ts +++ b/apps/files/src/views/personal-files.ts @@ -9,7 +9,7 @@ import { getContents } from '../services/PersonalFiles' import AccountIcon from '@mdi/svg/svg/account.svg?raw' import { loadState } from '@nextcloud/initial-state' -export default () => { +export const registerPersonalFilesView = () => { // Don't show this view if the user has no storage quota const storageStats = loadState('files', 'storageStats', { quota: -1 }) if (storageStats.quota === 0) { diff --git a/apps/files/src/views/recent.ts b/apps/files/src/views/recent.ts index fda1d99e13d0a..d1b4b99b0435a 100644 --- a/apps/files/src/views/recent.ts +++ b/apps/files/src/views/recent.ts @@ -8,7 +8,7 @@ import HistorySvg from '@mdi/svg/svg/history.svg?raw' import { getContents } from '../services/Recent' -export default () => { +export const registerRecentView = () => { const Navigation = getNavigation() Navigation.register(new View({ id: 'recent', From 3664761f875fa092ddfaa5f064e705c0cf6aeda8 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 27 Jun 2025 16:12:10 +0200 Subject: [PATCH 2/3] fixup! feat(files): add Home view Signed-off-by: skjnldsv --- apps/files/src/actions/openInFilesAction.ts | 8 +- apps/files/src/services/RecommendedFiles.ts | 92 ++++++++----------- .../files/src/views/FilesHeaderHomeSearch.vue | 4 +- apps/files/src/views/FilesList.vue | 7 +- apps/files/src/views/home.ts | 26 +++++- 5 files changed, 76 insertions(+), 61 deletions(-) diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts index 9e10b1ac74e17..741e37bd7c704 100644 --- a/apps/files/src/actions/openInFilesAction.ts +++ b/apps/files/src/actions/openInFilesAction.ts @@ -14,9 +14,11 @@ export const action = new FileAction({ displayName: () => t('files', 'Open in Files'), iconSvgInline: () => '', - enabled(nodes, view) { - return view.id === 'recent' || view.id === SEARCH_VIEW_ID - }, + enabled: (nodes, view: View) => [ + 'home', + 'recent', + SEARCH_VIEW_ID, + ].includes(view.id), async exec(node: Node) { let dir = node.dirname diff --git a/apps/files/src/services/RecommendedFiles.ts b/apps/files/src/services/RecommendedFiles.ts index 6c81d83bc5811..f76bdda820098 100644 --- a/apps/files/src/services/RecommendedFiles.ts +++ b/apps/files/src/services/RecommendedFiles.ts @@ -3,75 +3,63 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { ContentsWithRoot } from '@nextcloud/files' +import type { FileStat, ResponseDataDetailed } from 'webdav' import { CancelablePromise } from 'cancelable-promise' -import { File, Folder, Permission, } from '@nextcloud/files' -import { generateOcsUrl } from '@nextcloud/router' +import { File, Folder, Permission } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' -import { getRemoteURL, getRootPath } from '@nextcloud/files/dav' -import axios from '@nextcloud/axios' +import { getDefaultPropfind, getRemoteURL, registerDavProperty, resultToNode } from '@nextcloud/files/dav' +import { client } from './WebdavClient' +import logger from '../logger' +import { getCapabilities } from '@nextcloud/capabilities' +import { getContents as getRecentContents } from './Recent' -import { getContents as getDefaultContents } from './Files' - -type RecommendedFiles = { - 'id': string - 'timestamp': number - 'name': string - 'directory': string - 'extension': string - 'mimeType': string - 'hasPreview': boolean - 'reason': string -} - -type RecommendedFilesResponse = { - 'recommendations': RecommendedFiles[] +// Check if the recommendations capability is enabled +// If not, we'll just use recent files +const isRecommendationEnabled = getCapabilities()?.recommendations?.enabled === true +if (isRecommendationEnabled) { + registerDavProperty('nc:recommendation-reason', { nc: 'http://nextcloud.org/ns' }) + registerDavProperty('nc:recommendation-reason-label', { nc: 'http://nextcloud.org/ns' }) } -const fetchRecommendedFiles = (controller: AbortController): Promise => { - const url = generateOcsUrl('apps/recommendations/api/v1/recommendations/always') - - return axios.get(url, { - signal: controller.signal, - headers: { - 'OCS-APIRequest': 'true', - 'Content-Type': 'application/json', - }, - }).then(resp => resp.data.ocs.data as RecommendedFilesResponse) -} - -export const getContents = (path = '/'): CancelablePromise => { - if (path !== '/') { - return getDefaultContents(path) +export const getContents = (): CancelablePromise => { + if (!isRecommendationEnabled) { + logger.debug('Recommendations capability is not enabled, falling back to recent files') + return getRecentContents() } const controller = new AbortController() - return new CancelablePromise(async (resolve, reject, cancel) => { - cancel(() => controller.abort()) + const propfindPayload = getDefaultPropfind() + + return new CancelablePromise(async (resolve, reject, onCancel) => { + onCancel(() => controller.abort()) + + const root = `/recommendations/${getCurrentUser()?.uid}` try { - const { recommendations } = await fetchRecommendedFiles(controller) + const contentsResponse = await client.getDirectoryContents(root, { + details: true, + data: propfindPayload, + includeSelf: false, + signal: controller.signal, + }) as ResponseDataDetailed + const contents = contentsResponse.data resolve({ folder: new Folder({ id: 0, - source: `${getRemoteURL()}${getRootPath()}`, - root: getRootPath(), + source: `${getRemoteURL()}${root}`, + root, owner: getCurrentUser()?.uid || null, permissions: Permission.READ, }), - contents: recommendations.map((rec) => { - const Node = rec.mimeType === 'httpd/unix-directory' ? Folder : File - return new Node({ - id: parseInt(rec.id), - source: `${getRemoteURL()}/${getRootPath()}/${rec.directory}/${rec.name}`.replace(/\/\//g, '/'), - root: getRootPath(), - mime: rec.mimeType, - mtime: new Date(rec.timestamp * 1000), - owner: getCurrentUser()?.uid || null, - permissions: Permission.READ, - attributes: rec, - }) - }), + contents: contents.map((result) => { + try { + return resultToNode(result, root) + } catch (error) { + logger.error(`Invalid node detected '${result.basename}'`, { error }) + return null + } + }).filter(Boolean) as File[], }) } catch (error) { reject(error) diff --git a/apps/files/src/views/FilesHeaderHomeSearch.vue b/apps/files/src/views/FilesHeaderHomeSearch.vue index 39bd9ce1dbbc6..431a6f7b4e9e2 100644 --- a/apps/files/src/views/FilesHeaderHomeSearch.vue +++ b/apps/files/src/views/FilesHeaderHomeSearch.vue @@ -56,7 +56,7 @@ export default defineComponent({ diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 89d9fed6ce505..e5a28d97e4a63 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -3,7 +3,9 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> + + + @@ -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 0000000000000..b1e2e96c188a1 --- /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 6dcc479d16eb2..1940734cef326 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 d1b4b99b0435a..e64bc465bbca0 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.'),