diff --git a/apps/files/src/actions/openInFilesAction.ts b/apps/files/src/actions/openInFilesAction.ts
index 9e10b1ac74e17..e6feeab605145 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,10 @@ 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: Node[], view: View) => [
+ RECENT_VIEW_ID,
+ SEARCH_VIEW_ID,
+ ].includes(view.id),
async exec(node: Node) {
let dir = node.dirname
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index feb4b61c53e86..8834bff81d77d 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -48,6 +48,11 @@
:nodes="nodes" />
+
+
+
+
+
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 85dc5534e5eb6..4903ed950e3c7 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 5ae8220d594dd..c03865f631206 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -20,7 +20,14 @@
-
+
+
+
+
+
{{ 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/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..30c28a8319625
--- /dev/null
+++ b/apps/files/src/services/RecommendedFiles.ts
@@ -0,0 +1,71 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * 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 { getCurrentUser } from '@nextcloud/auth'
+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'
+
+// 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' })
+ registerDavProperty('nc:recommendation-original-location', { nc: 'http://nextcloud.org/ns' })
+}
+
+export const getContents = (): CancelablePromise => {
+ if (!isRecommendationEnabled) {
+ logger.debug('Recommendations capability is not enabled, falling back to recent files')
+ return getRecentContents()
+ }
+
+ const controller = new AbortController()
+ const propfindPayload = getDefaultPropfind()
+
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ onCancel(() => controller.abort())
+
+ const root = `/recommendations/${getCurrentUser()?.uid}`
+ try {
+ 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()}${root}`,
+ root,
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.READ,
+ }),
+ contents: contents.map((result) => {
+ try {
+ // 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
+ }
+ }).filter(Boolean) as File[],
+ })
+ } catch (error) {
+ reject(error)
+ }
+ })
+}
diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts
index ae6f1ee50e01d..8b2d86c0bea82 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 {
+export function getContents(query = ''): CancelablePromise {
const controller = new AbortController()
const searchStore = useSearchStore(getPinia())
@@ -26,7 +26,7 @@ export function getContents(): CancelablePromise {
return new CancelablePromise(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 {
}),
})
} 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/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/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts
index feb7f30b35768..7cfb0379650d7 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 {
+export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise {
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 3591832d0c416..1bf1b11c1fb1b 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 286cad253fc93..6e9b4632dbaf1 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('filter')
diff --git a/apps/files/src/views/FilesHeaderHomeSearch.vue b/apps/files/src/views/FilesHeaderHomeSearch.vue
new file mode 100644
index 0000000000000..01d703768587a
--- /dev/null
+++ b/apps/files/src/views/FilesHeaderHomeSearch.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index 89d9fed6ce505..ae3578988e9e8 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
-->
-
+