Skip to content

feat(files): add Home view #53739

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/files/src/actions/openInFilesAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
* 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({
id: 'open-in-files',
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
Expand Down
17 changes: 17 additions & 0 deletions apps/files/src/components/FilesListVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/components/FilesNavigationItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions apps/files/src/components/FilesNavigationSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/')
})

Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion apps/files/src/components/VirtualList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/files/src/composables/useFileListHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ export function useFileListHeaders(): ComputedRef<Header[]> {
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
}
27 changes: 14 additions & 13 deletions apps/files/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand All @@ -59,10 +60,11 @@ registerTemplateEntries()
if (isPublicShare() === false) {
registerFavoritesView()
registerFilesView()
registerFolderTreeView()
registerHomeView()
registerPersonalFilesView()
registerRecentView()
registerSearchView()
registerFolderTreeView()
}

// Register file list filters
Expand All @@ -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()
8 changes: 5 additions & 3 deletions apps/files/src/services/LivePhotos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}

Expand Down
71 changes: 71 additions & 0 deletions apps/files/src/services/RecommendedFiles.ts
Original file line number Diff line number Diff line change
@@ -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<ContentsWithRoot> => {
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<FileStat[]>

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)
}
})
}
10 changes: 8 additions & 2 deletions apps/files/src/services/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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({
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/files/src/services/ServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
11 changes: 8 additions & 3 deletions apps/files/src/services/WebDavSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,6 +17,8 @@ export interface SearchNodesOptions {
signal?: AbortSignal
}

export const MIN_SEARCH_LENGTH = 3

/**
* Search for nodes matching the given query.
*
Expand All @@ -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 []
}

Expand Down Expand Up @@ -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 []
}

Expand Down
Loading