Skip to content
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

feat(files): Load limited depth tree #47122

Merged
merged 6 commits into from
Aug 8, 2024
Merged
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
88 changes: 43 additions & 45 deletions apps/files/lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
*/
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;
Expand All @@ -29,12 +26,10 @@
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;
Expand Down Expand Up @@ -234,75 +229,78 @@ public function getRecentFiles() {
}

/**
* @param Folder[] $folders
* @param \OCP\Files\Node[] $nodes
* @param int $depth The depth to traverse into the contents of each node
*/
private function getTree(array $folders): array {
$user = $this->userSession->getUser();
if (!($user instanceof IUser)) {
throw new NotLoggedInException();
private function getChildren(array $nodes, int $depth = 1, int $currentDepth = 0): array {
if ($currentDepth >= $depth) {
return [];
}

$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$tree = [];
foreach ($folders as $folder) {
$path = $userFolder->getRelativePath($folder->getPath());
if ($path === null) {
$children = [];
foreach ($nodes as $node) {
if (!($node instanceof Folder)) {
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];

$basename = basename($node->getPath());
$entry = [
'id' => $node->getId(),
'basename' => $basename,
'children' => $this->getChildren($node->getDirectoryListing(), $depth, $currentDepth + 1),
];
$displayName = $node->getName();
if ($basename !== $displayName) {
$entry['displayName'] = $displayName;
}
$children[] = $entry;
}
return $tree['children'] ?? $tree;
return $children;
}

/**
* Returns the folder tree of the user
*
* @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED, array{message: string}, array{}>
* @param string $path The path relative to the user folder
* @param int $depth The depth of the tree
*
* @return JSONResponse<Http::STATUS_OK, FilesFolderTree, array{}>|JSONResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
*
* 200: Folder tree returned successfully
* 400: Invalid folder path
* 401: Unauthorized
* 404: Folder not found
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/folder-tree')]
public function getFolderTree(): JSONResponse {
public function getFolderTree(string $path = '/', int $depth = 1): 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);
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$userFolderPath = $userFolder->getPath();
$fullPath = implode('/', [$userFolderPath, trim($path, '/')]);
$node = $this->rootFolder->get($fullPath);
if (!($node instanceof Folder)) {
return new JSONResponse([
'message' => $this->l10n->t('Invalid folder path'),
], Http::STATUS_BAD_REQUEST);
}
$nodes = $node->getDirectoryListing();
$tree = $this->getChildren($nodes, $depth);
} catch (NotFoundException $e) {
return new JSONResponse([
'message' => $this->l10n->t('Folder not found'),
], Http::STATUS_NOT_FOUND);
} catch (Throwable $th) {
$this->logger->error($th->getMessage(), ['exception' => $th]);
$tree = [];
}
return new JSONResponse($tree, Http::STATUS_OK, [], JSON_FORCE_OBJECT);
return new JSONResponse($tree);
Fixed Show fixed Hide fixed
}

/**
Expand Down
9 changes: 4 additions & 5 deletions apps/files/lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,12 @@
* type: string,
* }
*
* @psalm-type FilesFolderTreeNode = array{
* @psalm-type FilesFolderTree = list<array{
* id: int,
* basename: string,
* displayName?: string,
* children?: array<string, array{}>,
* }
*
* @psalm-type FilesFolderTree = array<string, FilesFolderTreeNode>
* children: list<array{}>,
* }>
*
*/
class ResponseDefinitions {
Expand Down
105 changes: 83 additions & 22 deletions apps/files/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,28 +100,30 @@
}
},
"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"
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"basename",
"children"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"basename": {
"type": "string"
},
"displayName": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
Expand Down Expand Up @@ -1971,6 +1973,29 @@
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"default": "/",
"description": "The path relative to the user folder"
},
"depth": {
"type": "integer",
"format": "int64",
"default": 1,
"description": "The depth of the tree"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
Expand Down Expand Up @@ -2011,6 +2036,42 @@
}
}
}
},
"400": {
"description": "Invalid folder path",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"404": {
"description": "Folder not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
}
}
}
Expand Down
14 changes: 11 additions & 3 deletions apps/files/src/components/FilesNavigationItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
:key="view.id"
class="files-navigation__item"
allow-collapse
:loading="view.loading"
:data-cy-files-navigation-item="view.id"
:exact="useExactRouteMatching(view)"
:icon="view.iconClass"
Expand All @@ -17,11 +18,14 @@
:pinned="view.sticky"
:to="generateToNavigation(view)"
:style="style"
@update:open="onToggleExpand(view)">
@update:open="(open) => onOpen(open, view)">
<template v-if="view.icon" #icon>
<NcIconSvgWrapper :svg="view.icon" />
</template>
Pytal marked this conversation as resolved.
Show resolved Hide resolved

<!-- Hack to force the collapse icon to be displayed -->
<li v-if="view.loadChildViews && !view.loaded" style="display: none" />

<!-- Recursively nest child views -->
<FilesNavigationItem v-if="hasChildViews(view)"
:parent="view"
Expand Down Expand Up @@ -142,14 +146,18 @@ export default defineComponent({
/**
* Expand/collapse a a view with children and permanently
* save this setting in the server.
* @param view View to toggle
* @param open True if open
* @param view View
*/
onToggleExpand(view: View) {
async onOpen(open: boolean, 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)
if (open && view.loadChildViews) {
await view.loadChildViews(view)
}
},

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/files/src/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { View } from '@nextcloud/files'
import type { ShallowRef } from 'vue'

import { getNavigation } from '@nextcloud/files'
import { subscribe } from '@nextcloud/event-bus'
import { onMounted, onUnmounted, shallowRef, triggerRef } from 'vue'

/**
Expand Down Expand Up @@ -35,6 +36,7 @@ export function useNavigation() {
onMounted(() => {
navigation.addEventListener('update', onUpdateViews)
navigation.addEventListener('updateActive', onUpdateActive)
subscribe('files:navigation:updated', onUpdateViews)
})
onUnmounted(() => {
navigation.removeEventListener('update', onUpdateViews)
Expand Down
Loading
Loading