aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorArthur Schiwon <blizzz@arthur-schiwon.de>2024-08-01 20:47:59 +0200
committerGitHub <noreply@github.com>2024-08-01 20:47:59 +0200
commitef7d83044ab6417c88170525bafff133f27eef36 (patch)
treed69d73ba2b87eeaaa94eaccd05372b58cd6efada /apps
parent44887141489dab26ae74fb0841e8eb987c736221 (diff)
parent7f6d6d98960b14d072f3ad528777ed9391c6263b (diff)
downloadnextcloud-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.php5
-rw-r--r--apps/files/lib/AppInfo/Application.php6
-rw-r--r--apps/files/lib/Controller/ApiController.php122
-rw-r--r--apps/files/lib/ResponseDefinitions.php9
-rw-r--r--apps/files/lib/Service/UserConfig.php8
-rw-r--r--apps/files/openapi.json86
-rw-r--r--apps/files/src/components/BreadCrumbs.vue24
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue5
-rw-r--r--apps/files/src/components/FilesNavigationItem.vue170
-rw-r--r--apps/files/src/composables/useNavigation.ts4
-rw-r--r--apps/files/src/eventbus.d.ts5
-rw-r--r--apps/files/src/init.ts2
-rw-r--r--apps/files/src/services/FolderTree.ts90
-rw-r--r--apps/files/src/store/viewConfig.ts4
-rw-r--r--apps/files/src/views/Navigation.vue127
-rw-r--r--apps/files/src/views/Settings.vue5
-rw-r--r--apps/files/src/views/folderTree.ts153
-rw-r--r--apps/files/tests/Controller/ApiControllerTest.php17
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,
);
}