diff options
author | Arthur Schiwon <blizzz@arthur-schiwon.de> | 2024-08-01 20:47:59 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-08-01 20:47:59 +0200 |
commit | ef7d83044ab6417c88170525bafff133f27eef36 (patch) | |
tree | d69d73ba2b87eeaaa94eaccd05372b58cd6efada /apps | |
parent | 44887141489dab26ae74fb0841e8eb987c736221 (diff) | |
parent | 7f6d6d98960b14d072f3ad528777ed9391c6263b (diff) | |
download | nextcloud-server-ef7d83044ab6417c88170525bafff133f27eef36.tar.gz nextcloud-server-ef7d83044ab6417c88170525bafff133f27eef36.zip |
Merge pull request #46596 from nextcloud/feat/folder-tree
feat: Navigate via folder tree
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files/appinfo/routes.php | 5 | ||||
-rw-r--r-- | apps/files/lib/AppInfo/Application.php | 6 | ||||
-rw-r--r-- | apps/files/lib/Controller/ApiController.php | 122 | ||||
-rw-r--r-- | apps/files/lib/ResponseDefinitions.php | 9 | ||||
-rw-r--r-- | apps/files/lib/Service/UserConfig.php | 8 | ||||
-rw-r--r-- | apps/files/openapi.json | 86 | ||||
-rw-r--r-- | apps/files/src/components/BreadCrumbs.vue | 24 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 5 | ||||
-rw-r--r-- | apps/files/src/components/FilesNavigationItem.vue | 170 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.ts | 4 | ||||
-rw-r--r-- | apps/files/src/eventbus.d.ts | 5 | ||||
-rw-r--r-- | apps/files/src/init.ts | 2 | ||||
-rw-r--r-- | apps/files/src/services/FolderTree.ts | 90 | ||||
-rw-r--r-- | apps/files/src/store/viewConfig.ts | 4 | ||||
-rw-r--r-- | apps/files/src/views/Navigation.vue | 127 | ||||
-rw-r--r-- | apps/files/src/views/Settings.vue | 5 | ||||
-rw-r--r-- | apps/files/src/views/folderTree.ts | 153 | ||||
-rw-r--r-- | apps/files/tests/Controller/ApiControllerTest.php | 17 |
18 files changed, 716 insertions, 126 deletions
diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 8d1a33d6b4c..487f6335d45 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -61,6 +61,11 @@ $application->registerRoutes( 'verb' => 'PUT' ], [ + 'name' => 'Api#setViewConfig', + 'url' => '/api/v1/views', + 'verb' => 'PUT' + ], + [ 'name' => 'Api#getViewConfigs', 'url' => '/api/v1/views', 'verb' => 'GET' diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index de0542c8ad3..3d2d0527072 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -41,7 +41,9 @@ use OCP\Files\Events\Node\BeforeNodeCopiedEvent; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; use OCP\Files\Events\Node\BeforeNodeRenamedEvent; use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\IRootFolder; use OCP\IConfig; +use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; use OCP\IServerContainer; @@ -53,6 +55,7 @@ use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; use OCP\Share\IManager as IShareManager; use OCP\Util; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; class Application extends App implements IBootstrap { public const APP_ID = 'files'; @@ -80,6 +83,9 @@ class Application extends App implements IBootstrap { $server->getUserFolder(), $c->get(UserConfig::class), $c->get(ViewConfig::class), + $c->get(IL10N::class), + $c->get(IRootFolder::class), + $c->get(LoggerInterface::class), ); }); diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index 2581faa4d8d..0d4503682b0 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -7,12 +7,17 @@ */ namespace OCA\Files\Controller; +use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; use OC\Files\Node\Node; +use OC\Files\Search\SearchComparison; +use OC\Files\Search\SearchQuery; +use OCA\Files\ResponseDefinitions; use OCA\Files\Service\TagService; use OCA\Files\Service\UserConfig; use OCA\Files\Service\ViewConfig; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; @@ -24,48 +29,44 @@ use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StreamResponse; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\File; use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Files\Search\ISearchComparison; use OCP\IConfig; +use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; +use OCP\IUser; use OCP\IUserSession; use OCP\Share\IManager; use OCP\Share\IShare; +use Psr\Log\LoggerInterface; +use Throwable; /** + * @psalm-import-type FilesFolderTree from ResponseDefinitions + * * @package OCA\Files\Controller */ class ApiController extends Controller { - private TagService $tagService; - private IManager $shareManager; - private IPreview $previewManager; - private IUserSession $userSession; - private IConfig $config; - private ?Folder $userFolder; - private UserConfig $userConfig; - private ViewConfig $viewConfig; - public function __construct(string $appName, IRequest $request, - IUserSession $userSession, - TagService $tagService, - IPreview $previewManager, - IManager $shareManager, - IConfig $config, - ?Folder $userFolder, - UserConfig $userConfig, - ViewConfig $viewConfig) { + private IUserSession $userSession, + private TagService $tagService, + private IPreview $previewManager, + private IManager $shareManager, + private IConfig $config, + private ?Folder $userFolder, + private UserConfig $userConfig, + private ViewConfig $viewConfig, + private IL10N $l10n, + private IRootFolder $rootFolder, + private LoggerInterface $logger, + ) { parent::__construct($appName, $request); - $this->userSession = $userSession; - $this->tagService = $tagService; - $this->previewManager = $previewManager; - $this->shareManager = $shareManager; - $this->config = $config; - $this->userFolder = $userFolder; - $this->userConfig = $userConfig; - $this->viewConfig = $viewConfig; } /** @@ -232,6 +233,77 @@ class ApiController extends Controller { return new DataResponse(['files' => $files]); } + /** + * @param Folder[] $folders + */ + private function getTree(array $folders): array { + $user = $this->userSession->getUser(); + if (!($user instanceof IUser)) { + throw new NotLoggedInException(); + } + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $tree = []; + foreach ($folders as $folder) { + $path = $userFolder->getRelativePath($folder->getPath()); + if ($path === null) { + continue; + } + $pathBasenames = explode('/', trim($path, '/')); + $current = &$tree; + foreach ($pathBasenames as $basename) { + if (!isset($current['children'][$basename])) { + $current['children'][$basename] = [ + 'id' => $folder->getId(), + ]; + $displayName = $folder->getName(); + if ($displayName !== $basename) { + $current['children'][$basename]['displayName'] = $displayName; + } + } + $current = &$current['children'][$basename]; + } + } + return $tree['children'] ?? $tree; + } + + /** + * Returns the folder tree of the user + * + * @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}> + * + * 200: Folder tree returned successfully + * 401: Unauthorized + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/v1/folder-tree')] + public function getFolderTree(): JSONResponse { + $user = $this->userSession->getUser(); + if (!($user instanceof IUser)) { + return new JSONResponse([ + 'message' => $this->l10n->t('Failed to authorize'), + ], Http::STATUS_UNAUTHORIZED); + } + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + try { + $searchQuery = new SearchQuery( + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', ICacheEntry::DIRECTORY_MIMETYPE), + 0, + 0, + [], + $user, + false, + ); + /** @var Folder[] $folders */ + $folders = $userFolder->search($searchQuery); + $tree = $this->getTree($folders); + } catch (Throwable $th) { + $this->logger->error($th->getMessage(), ['exception' => $th]); + $tree = []; + } + return new JSONResponse($tree, Http::STATUS_OK, [], JSON_FORCE_OBJECT); + } /** * Returns the current logged-in user's storage stats. diff --git a/apps/files/lib/ResponseDefinitions.php b/apps/files/lib/ResponseDefinitions.php index 50893af7b11..79f7544fdac 100644 --- a/apps/files/lib/ResponseDefinitions.php +++ b/apps/files/lib/ResponseDefinitions.php @@ -38,6 +38,15 @@ namespace OCA\Files; * content: string, * type: string, * } + * + * @psalm-type FilesFolderTreeNode = array{ + * id: int, + * displayName?: string, + * children?: array<string, array{}>, + * } + * + * @psalm-type FilesFolderTree = array<string, FilesFolderTreeNode> + * */ class ResponseDefinitions { } diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php index b9b9248e172..c2339965793 100644 --- a/apps/files/lib/Service/UserConfig.php +++ b/apps/files/lib/Service/UserConfig.php @@ -42,6 +42,12 @@ class UserConfig { 'default' => false, 'allowed' => [true, false], ], + [ + // Whether to show the folder tree + 'key' => 'folder_tree', + 'default' => true, + 'allowed' => [true, false], + ], ]; protected IConfig $config; @@ -108,7 +114,7 @@ class UserConfig { if (!in_array($key, $this->getAllowedConfigKeys())) { throw new \InvalidArgumentException('Unknown config key'); } - + if (!in_array($value, $this->getAllowedConfigValues($key))) { throw new \InvalidArgumentException('Invalid config value'); } diff --git a/apps/files/openapi.json b/apps/files/openapi.json index e93c4d2807a..c9da18b0cd1 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -99,6 +99,33 @@ } } }, + "FolderTree": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FolderTreeNode" + } + }, + "FolderTreeNode": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "children": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, "OCSMeta": { "type": "object", "required": [ @@ -1928,6 +1955,65 @@ } } } + }, + "/ocs/v2.php/apps/files/api/v1/folder-tree": { + "get": { + "operationId": "api-get-folder-tree", + "summary": "Returns the folder tree of the user", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Folder tree returned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderTree" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index d93330e1d29..e42efeb6a85 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -109,12 +109,11 @@ export default defineComponent({ return this.dirs.map((dir: string, index: number) => { const source = this.getFileSourceFromPath(dir) const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined - const to = { ...this.$route, params: { node: node?.fileid }, query: { dir } } return { dir, exact: true, name: this.getDirDisplayName(dir), - to, + to: this.getTo(dir, node), // disable drop on current directory disableDrop: index === this.dirs.length - 1, } @@ -163,6 +162,27 @@ export default defineComponent({ return node?.displayname || basename(path) }, + getTo(dir: string, node?: Node): Record<string, unknown> { + if (dir === '/') { + return { + ...this.$route, + params: { view: this.currentView?.id }, + query: {}, + } + } + if (node === undefined) { + return { + ...this.$route, + query: { dir }, + } + } + return { + ...this.$route, + params: { fileid: String(node.fileid) }, + query: { dir: node.path }, + } + }, + onClick(to) { if (to?.query?.dir === this.$route.query.dir) { this.$emit('reload') diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index 1d45f7de17e..439037b984e 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -45,6 +45,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' import { FileType, NodeStatus } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' +import { dirname } from '@nextcloud/paths' import { defineComponent, inject } from 'vue' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' @@ -269,6 +270,10 @@ export default defineComponent({ // Success 🎉 emit('files:node:updated', this.source) emit('files:node:renamed', this.source) + emit('files:node:moved', { + node: this.source, + oldSource: `${dirname(this.source.source)}/${oldName}`, + }) showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) // Reset the renaming store diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue new file mode 100644 index 00000000000..75507803957 --- /dev/null +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -0,0 +1,170 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcAppNavigationItem v-for="view in currentViews" + :key="view.id" + class="files-navigation__item" + allow-collapse + :data-cy-files-navigation-item="view.id" + :exact="useExactRouteMatching(view)" + :icon="view.iconClass" + :name="view.name" + :open="isExpanded(view)" + :pinned="view.sticky" + :to="generateToNavigation(view)" + :style="style" + @update:open="onToggleExpand(view)"> + <template v-if="view.icon" #icon> + <NcIconSvgWrapper :svg="view.icon" /> + </template> + + <!-- Recursively nest child views --> + <FilesNavigationItem v-if="hasChildViews(view)" + :parent="view" + :level="level + 1" + :views="filterView(views, parent.id)" /> + </NcAppNavigationItem> + </Fragment> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { View } from '@nextcloud/files' + +import { defineComponent } from 'vue' +import { Fragment } from 'vue-frag' + +import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' + +import { useNavigation } from '../composables/useNavigation.js' +import { useViewConfigStore } from '../store/viewConfig.js' + +const maxLevel = 7 // Limit nesting to not exceed max call stack size + +export default defineComponent({ + name: 'FilesNavigationItem', + + components: { + Fragment, + NcAppNavigationItem, + NcIconSvgWrapper, + }, + + props: { + parent: { + type: Object as PropType<View>, + default: () => ({}), + }, + level: { + type: Number, + default: 0, + }, + views: { + type: Object as PropType<Record<string, View[]>>, + default: () => ({}), + }, + }, + + setup() { + const { currentView } = useNavigation() + const viewConfigStore = useViewConfigStore() + return { + currentView, + viewConfigStore, + } + }, + + computed: { + currentViews(): View[] { + if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level + return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) + .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) + } + return this.views[this.parent.id] ?? [] // Root level views have `undefined` parent ids + }, + + style() { + if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level + return null + } + return { + 'padding-left': '16px', + } + }, + }, + + methods: { + hasChildViews(view: View): boolean { + if (this.level >= maxLevel) { + return false + } + return this.views[view.id]?.length > 0 + }, + + /** + * Only use exact route matching on routes with child views + * Because if a view does not have children (like the files view) then multiple routes might be matched for it + * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view + * @param view The view to check + */ + useExactRouteMatching(view: View): boolean { + return this.hasChildViews(view) + }, + + /** + * Generate the route to a view + * @param view View to generate "to" navigation for + */ + generateToNavigation(view: View) { + if (view.params) { + const { dir } = view.params + return { name: 'filelist', params: { ...view.params }, query: { dir } } + } + return { name: 'filelist', params: { view: view.id } } + }, + + /** + * Check if a view is expanded by user config + * or fallback to the default value. + * @param view View to check if expanded + */ + isExpanded(view: View): boolean { + return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' + ? this.viewConfigStore.getConfig(view.id).expanded === true + : view.expanded === true + }, + + /** + * Expand/collapse a a view with children and permanently + * save this setting in the server. + * @param view View to toggle + */ + onToggleExpand(view: View) { + // Invert state + const isExpanded = this.isExpanded(view) + // Update the view expanded state, might not be necessary + view.expanded = !isExpanded + this.viewConfigStore.update(view.id, 'expanded', !isExpanded) + }, + + /** + * Return the view map with the specified view id removed + * + * @param viewMap Map of views + * @param id View id + */ + filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> { + return Object.fromEntries( + Object.entries(viewMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([viewId, _views]) => viewId !== id), + ) + }, + }, +}) +</script> diff --git a/apps/files/src/composables/useNavigation.ts b/apps/files/src/composables/useNavigation.ts index f410aec895f..2fff5633e23 100644 --- a/apps/files/src/composables/useNavigation.ts +++ b/apps/files/src/composables/useNavigation.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { View } from '@nextcloud/files' +import type { ShallowRef } from 'vue' import { getNavigation } from '@nextcloud/files' -import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue' +import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue' /** * Composable to get the currently active files view from the files navigation @@ -28,6 +29,7 @@ export function useNavigation() { */ function onUpdateViews() { views.value = navigation.views + triggerRef(views) } onMounted(() => { diff --git a/apps/files/src/eventbus.d.ts b/apps/files/src/eventbus.d.ts index db90c40eeae..e1fd8c73b4b 100644 --- a/apps/files/src/eventbus.d.ts +++ b/apps/files/src/eventbus.d.ts @@ -11,7 +11,12 @@ declare module '@nextcloud/event-bus' { 'files:favorites:removed': Node 'files:favorites:added': Node + + 'files:node:created': Node + 'files:node:deleted': Node + 'files:node:updated': Node 'files:node:renamed': Node + 'files:node:moved': { node: Node, oldSource: string } 'files:filter:added': IFileListFilter 'files:filter:removed': string diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index 4266453a4a3..846f1049d5a 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -27,6 +27,7 @@ import registerFavoritesView from './views/favorites' import registerRecentView from './views/recent' import registerPersonalFilesView from './views/personal-files' import registerFilesView from './views/files' +import { registerFolderTreeView } from './views/folderTree.ts' import registerPreviewServiceWorker from './services/ServiceWorker.js' import { initLivePhotos } from './services/LivePhotos' @@ -53,6 +54,7 @@ registerFavoritesView() registerFilesView() registerRecentView() registerPersonalFilesView() +registerFolderTreeView() // Register file list filters registerHiddenFilesFilter() diff --git a/apps/files/src/services/FolderTree.ts b/apps/files/src/services/FolderTree.ts new file mode 100644 index 00000000000..87c3aaa7db7 --- /dev/null +++ b/apps/files/src/services/FolderTree.ts @@ -0,0 +1,90 @@ +/** + * 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 { + davRemoteURL, + Folder, +} from '@nextcloud/files' +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { dirname, encodePath } from '@nextcloud/paths' + +import { getContents as getFiles } from './Files.ts' + +export const folderTreeId = 'folders' +export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}` + +interface TreeNodeData { + id: number, + displayName?: string, + // eslint-disable-next-line no-use-before-define + children?: Tree, +} + +interface Tree { + [basename: string]: TreeNodeData, +} + +export interface TreeNode { + source: string, + path: string, + fileid: number, + basename: string, + displayName?: string, +} + +const getTreeNodes = (tree: Tree, nodes: TreeNode[] = [], currentPath: string = ''): TreeNode[] => { + for (const basename in tree) { + const path = `${currentPath}/${basename}` + const node: TreeNode = { + source: `${sourceRoot}${path}`, + path, + fileid: tree[basename].id, + basename, + displayName: tree[basename].displayName, + } + nodes.push(node) + if (tree[basename].children) { + getTreeNodes(tree[basename].children, nodes, path) + } + } + return nodes +} + +export const getFolderTreeNodes = async (): Promise<TreeNode[]> => { + const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree')) + const nodes = getTreeNodes(tree) + return nodes +} + +export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path) + +export const encodeSource = (source: string): string => { + const { origin } = new URL(source) + return origin + encodePath(source.slice(origin.length)) +} + +export const getSourceParent = (source: string): string => { + const parent = dirname(source) + if (parent === sourceRoot) { + return folderTreeId + } + return encodeSource(parent) +} + +export const getFolderTreeViewId = (folder: Folder): string => { + return folder.encodedSource +} + +export const getFolderTreeParentId = (folder: Folder): string => { + if (folder.dirname === '/') { + return folderTreeId + } + return dirname(folder.encodedSource) +} diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts index f3021077c54..4dc40ba1015 100644 --- a/apps/files/src/store/viewConfig.ts +++ b/apps/files/src/store/viewConfig.ts @@ -44,8 +44,10 @@ export const useViewConfigStore = function(...args) { * @param value */ async update(view: ViewId, key: string, value: string | number | boolean) { - axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), { + axios.put(generateUrl('/apps/files/api/v1/views'), { value, + view, + key, }) emit('files:viewconfig:updated', { view, key, value }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index b0588863f5d..cca38824d2c 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -4,38 +4,14 @@ --> <template> <NcAppNavigation data-cy-files-navigation + class="files-navigation" :aria-label="t('files', 'Files')"> <template #search> <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" /> </template> <template #default> <NcAppNavigationList :aria-label="t('files', 'Views')"> - <NcAppNavigationItem v-for="view in parentViews" - :key="view.id" - :allow-collapse="true" - :data-cy-files-navigation-item="view.id" - :exact="useExactRouteMatching(view)" - :icon="view.iconClass" - :name="view.name" - :open="isExpanded(view)" - :pinned="view.sticky" - :to="generateToNavigation(view)" - @update:open="onToggleExpand(view)"> - <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> - - <!-- Child views if any --> - <NcAppNavigationItem v-for="child in childViews[view.id]" - :key="child.id" - :data-cy-files-navigation-item="child.id" - :exact-path="true" - :icon="child.iconClass" - :name="child.name" - :to="generateToNavigation(child)"> - <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" /> - </NcAppNavigationItem> - </NcAppNavigationItem> + <FilesNavigationItem :views="viewMap" /> </NcAppNavigationList> <!-- Settings modal--> @@ -65,7 +41,7 @@ import type { View } from '@nextcloud/files' import { emit } from '@nextcloud/event-bus' -import { t } from '@nextcloud/l10n' +import { translate as t, getCanonicalLocale, getLanguage } from '@nextcloud/l10n' import { defineComponent } from 'vue' import IconCog from 'vue-material-design-icons/Cog.vue' @@ -73,9 +49,9 @@ import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' import NcAppNavigationList from '@nextcloud/vue/dist/Components/NcAppNavigationList.js' import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NavigationQuota from '../components/NavigationQuota.vue' import SettingsModal from './Settings.vue' +import FilesNavigationItem from '../components/FilesNavigationItem.vue' import { useNavigation } from '../composables/useNavigation' import { useFilenameFilter } from '../composables/useFilenameFilter' @@ -83,18 +59,26 @@ import { useFiltersStore } from '../store/filters.ts' import { useViewConfigStore } from '../store/viewConfig.ts' import logger from '../logger.ts' +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) + export default defineComponent({ name: 'Navigation', components: { IconCog, + FilesNavigationItem, NavigationQuota, NcAppNavigation, NcAppNavigationItem, NcAppNavigationList, NcAppNavigationSearch, - NcIconSvgWrapper, SettingsModal, }, @@ -129,28 +113,21 @@ export default defineComponent({ return this.$route?.params?.view || 'files' }, - parentViews(): View[] { - return this.views - // filter child views - .filter(view => !view.parent) - // sort views by order - .sort((a, b) => { - return a.order - b.order - }) - }, - - childViews(): Record<string, View[]> { + /** + * Map of parent ids to views + */ + viewMap(): Record<string, View[]> { return this.views - // filter parent views - .filter(view => !!view.parent) - // create a map of parents and their children - .reduce((list, view) => { - list[view.parent!] = [...(list[view.parent!] || []), view] - // Sort children by order - list[view.parent!].sort((a, b) => { - return a.order - b.order + .reduce((map, view) => { + map[view.parent!] = [...(map[view.parent!] || []), view] + // TODO Allow undefined order for natural sort + map[view.parent!].sort((a, b) => { + if (typeof a.order === 'number' || typeof b.order === 'number') { + return (a.order ?? 0) - (b.order ?? 0) + } + return collator.compare(a.name, b.name) }) - return list + return map }, {} as Record<string, View[]>) }, }, @@ -176,16 +153,6 @@ export default defineComponent({ methods: { /** - * Only use exact route matching on routes with child views - * Because if a view does not have children (like the files view) then multiple routes might be matched for it - * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view - * @param view The view to check - */ - useExactRouteMatching(view: View): boolean { - return this.childViews[view.id]?.length > 0 - }, - - /** * Set the view as active on the navigation and handle internal state * @param view View to set active */ @@ -197,42 +164,6 @@ export default defineComponent({ }, /** - * Expand/collapse a a view with children and permanently - * save this setting in the server. - * @param view View to toggle - */ - onToggleExpand(view: View) { - // Invert state - const isExpanded = this.isExpanded(view) - // Update the view expanded state, might not be necessary - view.expanded = !isExpanded - this.viewConfigStore.update(view.id, 'expanded', !isExpanded) - }, - - /** - * Check if a view is expanded by user config - * or fallback to the default value. - * @param view View to check if expanded - */ - isExpanded(view: View): boolean { - return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' - ? this.viewConfigStore.getConfig(view.id).expanded === true - : view.expanded === true - }, - - /** - * Generate the route to a view - * @param view View to generate "to" navigation for - */ - generateToNavigation(view: View) { - if (view.params) { - const { dir } = view.params - return { name: 'filelist', params: view.params, query: { dir } } - } - return { name: 'filelist', params: { view: view.id } } - }, - - /** * Open the settings modal */ openSettings() { @@ -272,4 +203,10 @@ export default defineComponent({ // Prevent shrinking or growing flex: 0 0 auto; } + +.files-navigation { + :deep(.app-navigation__content > ul.app-navigation__list) { + will-change: scroll-position; + } +} </style> diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue index b5d85b60729..c64f3b898b2 100644 --- a/apps/files/src/views/Settings.vue +++ b/apps/files/src/views/Settings.vue @@ -35,6 +35,11 @@ @update:checked="setConfig('grid_view', $event)"> {{ t('files', 'Enable the grid view') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch data-cy-files-settings-setting="folder_tree" + :checked="userConfig.folder_tree" + @update:checked="setConfig('folder_tree', $event)"> + {{ t('files', 'Enable folder tree') }} + </NcCheckboxRadioSwitch> </NcAppSettingsSection> <!-- Settings API--> diff --git a/apps/files/src/views/folderTree.ts b/apps/files/src/views/folderTree.ts new file mode 100644 index 00000000000..a466a838f66 --- /dev/null +++ b/apps/files/src/views/folderTree.ts @@ -0,0 +1,153 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { TreeNode } from '../services/FolderTree.ts' + +import { Folder, Node, View, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { subscribe } from '@nextcloud/event-bus' +import { isSamePath } from '@nextcloud/paths' +import { loadState } from '@nextcloud/initial-state' + +import FolderSvg from '@mdi/svg/svg/folder.svg?raw' +import FolderMultipleSvg from '@mdi/svg/svg/folder-multiple.svg?raw' + +import { + encodeSource, + folderTreeId, + getContents, + getFolderTreeNodes, + getFolderTreeParentId, + getFolderTreeViewId, + getSourceParent, + sourceRoot, +} from '../services/FolderTree.ts' + +const isFolderTreeEnabled = loadState('files', 'config', { folder_tree: true }).folder_tree + +const Navigation = getNavigation() + +const registerTreeNodeView = (node: TreeNode) => { + Navigation.register(new View({ + id: encodeSource(node.source), + parent: getSourceParent(node.source), + + name: node.displayName ?? node.basename, + + icon: FolderSvg, + order: 0, // TODO Allow undefined order for natural sort + + getContents, + + params: { + view: folderTreeId, + fileid: String(node.fileid), // Needed for matching exact routes + dir: node.path, + }, + })) +} + +const registerFolderView = (folder: Folder) => { + Navigation.register(new View({ + id: getFolderTreeViewId(folder), + parent: getFolderTreeParentId(folder), + + name: folder.displayname, + + icon: FolderSvg, + order: 0, // TODO Allow undefined order for natural sort + + getContents, + + params: { + view: folderTreeId, + fileid: String(folder.fileid), + dir: folder.path, + }, + })) +} + +const removeFolderView = (folder: Folder) => { + const viewId = getFolderTreeViewId(folder) + Navigation.remove(viewId) +} + +const removeFolderViewSource = (source: string) => { + const Navigation = getNavigation() + Navigation.remove(source) +} + +const onCreateNode = (node: Node) => { + if (!(node instanceof Folder)) { + return + } + registerFolderView(node) +} + +const onDeleteNode = (node: Node) => { + if (!(node instanceof Folder)) { + return + } + removeFolderView(node) +} + +const onMoveNode = ({ node, oldSource }) => { + if (!(node instanceof Folder)) { + return + } + removeFolderViewSource(oldSource) + registerFolderView(node) + + const newPath = node.source.replace(sourceRoot, '') + const oldPath = oldSource.replace(sourceRoot, '') + const childViews = Navigation.views.filter(view => { + if (!view.params?.dir) { + return false + } + if (isSamePath(view.params.dir, oldPath)) { + return false + } + return view.params.dir.startsWith(oldPath) + }) + for (const view of childViews) { + // @ts-expect-error FIXME Allow setting parent + view.parent = getFolderTreeParentId(node) + // @ts-expect-error dir param is defined + view.params.dir = view.params.dir.replace(oldPath, newPath) + } +} + +const registerFolderTreeRoot = () => { + Navigation.register(new View({ + id: folderTreeId, + + name: t('files', 'All folders'), + caption: t('files', 'List of your files and folders.'), + + icon: FolderMultipleSvg, + order: 50, // Below all other views + + getContents, + })) +} + +const registerFolderTreeChildren = async () => { + const nodes = await getFolderTreeNodes() + for (const node of nodes) { + registerTreeNodeView(node) + } + + subscribe('files:node:created', onCreateNode) + subscribe('files:node:deleted', onDeleteNode) + subscribe('files:node:moved', onMoveNode) +} + +export const registerFolderTreeView = async () => { + if (!isFolderTreeEnabled) { + return + } + registerFolderTreeRoot() + await registerFolderTreeChildren() +} diff --git a/apps/files/tests/Controller/ApiControllerTest.php b/apps/files/tests/Controller/ApiControllerTest.php index 844fabc93a3..f79a5c7bb64 100644 --- a/apps/files/tests/Controller/ApiControllerTest.php +++ b/apps/files/tests/Controller/ApiControllerTest.php @@ -14,15 +14,18 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\Files\File; use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; +use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; use OCP\Share\IManager; +use Psr\Log\LoggerInterface; use Test\TestCase; /** @@ -53,6 +56,12 @@ class ApiControllerTest extends TestCase { private $userConfig; /** @var ViewConfig|\PHPUnit\Framework\MockObject\MockObject */ private $viewConfig; + /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */ + private $l10n; + /** @var IRootFolder|\PHPUnit\Framework\MockObject\MockObject */ + private $rootFolder; + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; protected function setUp(): void { parent::setUp(); @@ -83,6 +92,9 @@ class ApiControllerTest extends TestCase { ->getMock(); $this->userConfig = $this->createMock(UserConfig::class); $this->viewConfig = $this->createMock(ViewConfig::class); + $this->l10n = $this->createMock(IL10N::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->apiController = new ApiController( $this->appName, @@ -94,7 +106,10 @@ class ApiControllerTest extends TestCase { $this->config, $this->userFolder, $this->userConfig, - $this->viewConfig + $this->viewConfig, + $this->l10n, + $this->rootFolder, + $this->logger, ); } |