Browse Source

Port Files navigation to vue

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
John Molakvoæ 1 year ago
parent
commit
887c9e05de

+ 22 - 11
apps/files/appinfo/routes.php

@@ -45,13 +45,34 @@ $application->registerRoutes(
 	$this,
 	[
 		'routes' => [
+			[
+				'name' => 'view#index',
+				'url' => '/',
+				'verb' => 'GET',
+			],
+			[
+				'name' => 'view#index',
+				'url' => '/{view}',
+				'verb' => 'GET',
+				'postfix' => 'view',
+			],
+			[
+				'name' => 'view#index',
+				'url' => '/{view}/{fileid}',
+				'verb' => 'GET',
+				'postfix' => 'fileid',
+			],
 			[
 				'name' => 'View#showFile',
 				'url' => '/f/{fileid}',
 				'verb' => 'GET',
 				'root' => '',
 			],
-
+			[
+				'name' => 'ajax#getStorageStats',
+				'url' => '/ajax/getstoragestats',
+				'verb' => 'GET',
+			],
 			[
 				'name' => 'API#getThumbnail',
 				'url' => '/api/v1/thumbnail/{x}/{y}/{file}',
@@ -94,16 +115,6 @@ $application->registerRoutes(
 				'url' => '/api/v1/showgridview',
 				'verb' => 'GET'
 			],
-			[
-				'name' => 'view#index',
-				'url' => '/',
-				'verb' => 'GET',
-			],
-			[
-				'name' => 'ajax#getStorageStats',
-				'url' => '/ajax/getstoragestats',
-				'verb' => 'GET',
-			],
 			[
 				'name' => 'API#toggleShowFolder',
 				'url' => '/api/v1/toggleShowFolder/{key}',

+ 32 - 22
apps/files/js/app.js

@@ -27,9 +27,9 @@
 	 */
 	OCA.Files.App = {
 		/**
-		 * Navigation control
+		 * Navigation instance
 		 *
-		 * @member {OCA.Files.Navigation}
+		 * @member {OCP.Files.Navigation}
 		 */
 		navigation: null,
 
@@ -51,7 +51,7 @@
 		 * Initializes the files app
 		 */
 		initialize: function() {
-			this.navigation = new OCA.Files.Navigation($('#app-navigation'));
+			this.navigation = OCP.Files.Navigation;
 			this.$showHiddenFiles = $('input#showhiddenfilesToggle');
 			var showHidden = $('#showHiddenFiles').val() === "1";
 			this.$showHiddenFiles.prop('checked', showHidden);
@@ -159,7 +159,6 @@
 		 * Destroy the app
 		 */
 		destroy: function() {
-			this.navigation = null;
 			this.fileList.destroy();
 			this.fileList = null;
 			this.files = null;
@@ -216,7 +215,8 @@
 		 * @return app container
 		 */
 		getCurrentAppContainer: function() {
-			return this.navigation.getActiveContainer();
+			var viewId = this.getActiveView();
+			return $('#app-content-' + viewId);
 		},
 
 		/**
@@ -224,7 +224,7 @@
 		 * @param viewId view id
 		 */
 		setActiveView: function(viewId, options) {
-			this.navigation.setActiveItem(viewId, options);
+			window._nc_event_bus.emit('files:view:changed', { id: viewId })
 		},
 
 		/**
@@ -232,7 +232,8 @@
 		 * @return view id
 		 */
 		getActiveView: function() {
-			return this.navigation.getActiveItem();
+			return this.navigation.active
+				&& this.navigation.active.id;
 		},
 
 		/**
@@ -254,6 +255,7 @@
 			$('#app-content').delegate('>div', 'afterChangeDirectory', _.bind(this._onAfterDirectoryChanged, this));
 			$('#app-content').delegate('>div', 'changeViewerMode', _.bind(this._onChangeViewerMode, this));
 
+			window._nc_event_bus.subscribe('files:view:changed', _.bind(this._onNavigationChanged, this))
 			$('#app-navigation').on('itemChanged', _.bind(this._onNavigationChanged, this));
 			this.$showHiddenFiles.on('change', _.bind(this._onShowHiddenFilesChange, this));
 			this.$cropImagePreviews.on('change', _.bind(this._onCropImagePreviewsChange, this));
@@ -308,16 +310,24 @@
 		/**
 		 * Event handler for when the current navigation item has changed
 		 */
-		_onNavigationChanged: function(e) {
+		_onNavigationChanged: function(view) {
 			var params;
-			if (e && e.itemId) {
-				params = {
-					view: typeof e.view === 'string' && e.view !== '' ? e.view : e.itemId,
-					dir: e.dir ? e.dir : '/'
-				};
+			if (view && (view.itemId || view.id)) {
+				if (view.id) {
+					params = {
+						view: view.id,
+						dir: '/',
+					}
+				} else {
+					// Legacy handling
+					params = {
+						view: typeof view.view === 'string' && view.view !== '' ? view.view : view.itemId,
+						dir: view.dir ? view.dir : '/'
+					}
+				}
 				this._changeUrl(params.view, params.dir);
 				OCA.Files.Sidebar.close();
-				this.navigation.getActiveContainer().trigger(new $.Event('urlChanged', params));
+				this.getCurrentAppContainer().trigger(new $.Event('urlChanged', params));
 				window._nc_event_bus.emit('files:navigation:changed')
 			}
 		},
@@ -327,7 +337,7 @@
 		 */
 		_onDirectoryChanged: function(e) {
 			if (e.dir && !e.changedThroughUrl) {
-				this._changeUrl(this.navigation.getActiveItem(), e.dir, e.fileId);
+				this._changeUrl(this.getActiveView(), e.dir, e.fileId);
 			}
 		},
 
@@ -336,7 +346,7 @@
 		 */
 		_onAfterDirectoryChanged: function(e) {
 			if (e.dir && e.fileId) {
-				this._changeUrl(this.navigation.getActiveItem(), e.dir, e.fileId);
+				this._changeUrl(this.getActiveView(), e.dir, e.fileId);
 			}
 		},
 
@@ -361,15 +371,15 @@
 				dir: '/',
 				view: 'files'
 			}, params);
-			var lastId = this.navigation.getActiveItem();
-			if (!this.navigation.itemExists(params.view)) {
+			var lastId = this.navigation.active;
+			if (!this.navigation.views.find(view => view.id === params.view)) {
 				params.view = 'files';
 			}
-			this.navigation.setActiveItem(params.view, {silent: true});
-			if (lastId !== this.navigation.getActiveItem()) {
-				this.navigation.getActiveContainer().trigger(new $.Event('show'));
+			this.setActiveView(params.view, {silent: true});
+			if (lastId !== this.getActiveView()) {
+				this.getCurrentAppContainer().trigger(new $.Event('show'));
 			}
-			this.navigation.getActiveContainer().trigger(new $.Event('urlChanged', params));
+			this.getCurrentAppContainer().trigger(new $.Event('urlChanged', params));
 			window._nc_event_bus.emit('files:navigation:changed')
 		},
 

+ 0 - 1
apps/files/js/merged-index.json

@@ -19,7 +19,6 @@
   "jquery.fileupload.js",
   "keyboardshortcuts.js",
   "mainfileinfodetailview.js",
-  "navigation.js",
   "newfilemenu.js",
   "operationprogressbar.js",
   "recentfilelist.js",

+ 0 - 1
apps/files/lib/AppInfo/Application.php

@@ -172,7 +172,6 @@ class Application extends App implements IBootstrap {
 				'script' => 'simplelist.php',
 				'order' => 5,
 				'name' => $l10n->t('Favorites'),
-				'expandedState' => 'show_Quick_Access'
 			];
 		});
 	}

+ 11 - 11
apps/files/lib/Controller/ApiController.php

@@ -346,18 +346,18 @@ class ApiController extends Controller {
 	 * @throws \OCP\PreConditionNotMetException
 	 */
 	public function toggleShowFolder(int $show, string $key): Response {
-		// ensure the edited key exists
-		$navItems = \OCA\Files\App::getNavigationManager()->getAll();
-		foreach ($navItems as $item) {
-			// check if data is valid
-			if (($show === 0 || $show === 1) && isset($item['expandedState']) && $key === $item['expandedState']) {
-				$this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', $key, (string)$show);
-				return new Response();
-			}
+		if ($show !== 0 && $show !== 1) {
+			return new DataResponse([
+				'message' => 'Invalid show value. Only 0 and 1 are allowed.'
+			], Http::STATUS_BAD_REQUEST);
 		}
-		$response = new Response();
-		$response->setStatus(Http::STATUS_FORBIDDEN);
-		return $response;
+
+		$userId = $this->userSession->getUser()->getUID();
+
+		// Set the new value and return it
+		// Using a prefix prevents the user from setting arbitrary keys
+		$this->config->setUserValue($userId, 'files', 'show_' . $key, (string)$show);
+		return new JSONResponse([$key => $show]);
 	}
 
 	/**

+ 21 - 20
apps/files/lib/Controller/ViewController.php

@@ -186,13 +186,14 @@ class ViewController extends Controller {
 	 * @throws NotFoundException
 	 */
 	public function index($dir = '', $view = '', $fileid = null, $fileNotFound = false, $openfile = null) {
-		if ($fileid !== null && $dir === '') {
-			try {
-				return $this->redirectToFile($fileid);
-			} catch (NotFoundException $e) {
-				return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
-			}
-		}
+
+		// if ($fileid !== null && $dir === '') {
+		// 	try {
+		// 		return $this->redirectToFile($fileid);
+		// 	} catch (NotFoundException $e) {
+		// 		return new RedirectResponse($this->urlGenerator->linkToRoute('files.view.index', ['fileNotFound' => true]));
+		// 	}
+		// }
 
 		$nav = new \OCP\Template('files', 'appnavigation', '');
 
@@ -205,11 +206,11 @@ class ViewController extends Controller {
 		// FIXME: Make non static
 		$storageInfo = $this->getStorageInfo();
 
-		$user = $this->userSession->getUser()->getUID();
+		$userId = $this->userSession->getUser()->getUID();
 
 		// Get all the user favorites to create a submenu
 		try {
-			$favElements = $this->activityHelper->getFavoriteFilePaths($this->userSession->getUser()->getUID());
+			$favElements = $this->activityHelper->getFavoriteFilePaths($userId);
 		} catch (\RuntimeException $e) {
 			$favElements['folders'] = [];
 		}
@@ -234,7 +235,7 @@ class ViewController extends Controller {
 				'order' => $navBarPositionPosition,
 				'folderPosition' => $sortingValue,
 				'name' => basename($favElement),
-				'icon' => 'files',
+				'icon' => 'folder',
 				'quickaccesselement' => 'true'
 			];
 
@@ -248,11 +249,9 @@ class ViewController extends Controller {
 		$navItems['favorites']['sublist'] = $favoritesSublistArray;
 		$navItems['favorites']['classes'] = $collapseClasses;
 
-		// parse every menu and add the expandedState user value
+		// parse every menu and add the expanded user value
 		foreach ($navItems as $key => $item) {
-			if (isset($item['expandedState'])) {
-				$navItems[$key]['defaultExpandedState'] = $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', $item['expandedState'], '0') === '1';
-			}
+			$navItems[$key]['expanded'] = $this->config->getUserValue($userId, 'files', 'show_' . $item['id'], '0') === '1';
 		}
 
 		$nav->assign('navigationItems', $navItems);
@@ -267,10 +266,12 @@ class ViewController extends Controller {
 		$nav->assign('quota', $storageInfo['quota']);
 		$nav->assign('usage_relative', $storageInfo['relative']);
 
-		$nav->assign('webdav_url', \OCP\Util::linkToRemote('dav/files/' . rawurlencode($user)));
+		$nav->assign('webdav_url', \OCP\Util::linkToRemote('dav/files/' . rawurlencode($userId)));
 
 		$contentItems = [];
 
+		$this->initialState->provideInitialState('navigation', $navItems);
+
 		// render the container content for every navigation item
 		foreach ($navItems as $item) {
 			$content = '';
@@ -314,12 +315,12 @@ class ViewController extends Controller {
 		$params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? '';
 		$params['isPublic'] = false;
 		$params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no';
-		$params['defaultFileSorting'] = $this->config->getUserValue($user, 'files', 'file_sorting', 'name');
-		$params['defaultFileSortingDirection'] = $this->config->getUserValue($user, 'files', 'file_sorting_direction', 'asc');
-		$params['showgridview'] = $this->config->getUserValue($user, 'files', 'show_grid', false);
-		$showHidden = (bool) $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'show_hidden', false);
+		$params['defaultFileSorting'] = $this->config->getUserValue($userId, 'files', 'file_sorting', 'name');
+		$params['defaultFileSortingDirection'] = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc');
+		$params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false);
+		$showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false);
 		$params['showHiddenFiles'] = $showHidden ? 1 : 0;
-		$cropImagePreviews = (bool) $this->config->getUserValue($this->userSession->getUser()->getUID(), 'files', 'crop_image_previews', true);
+		$cropImagePreviews = (bool) $this->config->getUserValue($userId, 'files', 'crop_image_previews', true);
 		$params['cropImagePreviews'] = $cropImagePreviews ? 1 : 0;
 		$params['fileNotFound'] = $fileNotFound ? 1 : 0;
 		$params['appNavigation'] = $nav;

+ 54 - 0
apps/files/src/legacy/navigationMapper.js

@@ -0,0 +1,54 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+import logger from '../logger.js'
+
+/**
+ * Fetch and register the legacy files views
+ */
+export default function() {
+	const legacyViews = Object.values(loadState('files', 'navigation', {}))
+
+	if (legacyViews.length > 0) {
+		logger.debug('Legacy files views detected. Processing...', legacyViews)
+		legacyViews.forEach(view => {
+			registerLegacyView(view)
+			if (view.sublist) {
+				view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id }))
+			}
+		})
+	}
+}
+
+const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded }) {
+	OCP.Files.Navigation.register({
+		id,
+		name,
+		iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id,
+		order,
+		parent,
+		legacy: true,
+		sticky: classes.includes('pinned'),
+		expanded: expanded === true,
+	})
+}

+ 6 - 18
apps/files/src/logger.js

@@ -1,8 +1,7 @@
 /**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
  *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
  *
  * @license AGPL-3.0-or-later
  *
@@ -20,20 +19,9 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  */
-
-import { getCurrentUser } from '@nextcloud/auth'
 import { getLoggerBuilder } from '@nextcloud/logger'
 
-const getLogger = user => {
-	if (user === null) {
-		return getLoggerBuilder()
-			.setApp('files')
-			.build()
-	}
-	return getLoggerBuilder()
-		.setApp('files')
-		.setUid(user.uid)
-		.build()
-}
-
-export default getLogger(getCurrentUser())
+export default getLoggerBuilder()
+	.setApp('files')
+	.detectUser()
+	.build()

+ 31 - 3
apps/files/src/main.js

@@ -1,3 +1,31 @@
-import './files-app-settings'
-import './templates'
-import './legacy/filelistSearch'
+import './files-app-settings.js'
+import './templates.js'
+import './legacy/filelistSearch.js'
+import processLegacyFilesViews from './legacy/navigationMapper.js'
+
+import Vue from 'vue'
+import NavigationService from './services/Navigation.ts'
+import NavigationView from './views/Navigation.vue'
+
+import router from './router/router.js'
+
+// Init Files App Navigation Service
+const Navigation = new NavigationService()
+
+// Assign Navigation Service to the global OCP.Files
+window.OCP.Files = window.OCP.Files ?? {}
+Object.assign(window.OCP.Files, { Navigation })
+
+// Init Navigation View
+const View = Vue.extend(NavigationView)
+const FilesNavigationRoot = new View({
+	name: 'FilesNavigationRoot',
+	propsData: {
+		Navigation,
+	},
+	router,
+})
+FilesNavigationRoot.$mount('#app-navigation-files')
+
+// Init legacy files views
+processLegacyFilesViews()

+ 52 - 0
apps/files/src/router/router.js

@@ -0,0 +1,52 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import Vue from 'vue'
+import Router from 'vue-router'
+import { generateUrl } from '@nextcloud/router'
+
+Vue.use(Router)
+
+export default new Router({
+	mode: 'history',
+
+	// if index.php is in the url AND we got this far, then it's working:
+	// let's keep using index.php in the url
+	base: generateUrl('/apps/files', ''),
+	linkActiveClass: 'active',
+
+	routes: [
+		{
+			path: '/',
+			// Pretending we're using the default view
+			alias: '/files',
+		},
+		{
+			path: '/:view/:fileId?',
+			name: 'filelist',
+			props: true,
+		},
+		{
+			path: '/not-found',
+			name: 'notfound',
+		},
+	],
+})

+ 217 - 0
apps/files/src/services/Navigation.ts

@@ -0,0 +1,217 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import type Node from '@nextcloud/files/dist/files/node'
+import isSvg from 'is-svg'
+
+import logger from '../logger'
+
+export interface Column {
+	/** Unique column ID */
+	id: string
+	/** Translated column title */
+	title: string
+	/** Property key from Node main or additional attributes.
+	Will be used if no custom sort function is provided.
+	Sorting will be done by localCompare */
+	property: string
+	/** Special function used to sort Nodes between them */
+	sortFunction?: (nodeA: Node, nodeB: Node) => number;
+	/** Custom summary of the column to display at the end of the list.
+	 Will not be displayed if  nothing is provided */
+	summary?: (node: Node[]) => string
+}
+
+export interface Navigation {
+	/** Unique view ID */
+	id: string
+	/** Translated view name */
+	name: string
+	/** Method return the content of the  provided path */
+	getFiles: (path: string) => Node[]
+	/** The view icon as an inline svg */
+	icon: string
+	/** The view order */
+	order: number
+	/** This view column(s). Name and actions are
+	by default always included */
+	columns?: Column[]
+	/** The empty view element to render your empty content into */
+	emptyView?: (div: HTMLDivElement) => void
+	/** The parent unique ID */
+	parent?: string
+	/** This view is sticky (sent at the bottom) */
+	sticky?: boolean
+	/** This view has children and is expanded or not */
+	expanded?: boolean
+
+	/**
+	 * This view is sticky a legacy view.
+	 * Here until all the views are migrated to Vue.
+	 * @deprecated It will be removed in a near future
+	 */
+	legacy?: boolean
+	/**
+	 * An icon class. 
+	 * @deprecated It will be removed in a near future
+	 */
+	iconClass?: string
+}
+
+export default class {
+
+	private _views: Navigation[] = []
+	private _currentView: Navigation | null = null
+
+	constructor() {
+		logger.debug('Navigation service initialized')
+	}
+
+	register(view: Navigation) {
+		try {
+			isValidNavigation(view)
+			isUniqueNavigation(view, this._views)
+		} catch (e) {
+			if (e instanceof Error) {
+				logger.error(e.message, { view })
+			}
+			throw e
+		}
+
+		if (view.legacy) {
+			logger.warn('Legacy view detected, please migrate to Vue')
+		}
+
+		if (view.iconClass) {
+			view.legacy = true
+		}
+
+		this._views.push(view)
+	}
+
+	get views(): Navigation[] {
+		return this._views
+	}
+
+	setActive(view: Navigation | null) {
+		this._currentView = view
+	}
+
+	get active(): Navigation | null {
+		return this._currentView
+	}
+
+}
+
+/**
+ * Make sure the given view is unique
+ * and not already registered.
+ */
+const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean {
+	if (views.find(search => search.id === view.id)) {
+		throw new Error(`Navigation id ${view.id} is already registered`)
+	}
+	return true
+}
+
+/**
+ * Typescript cannot validate an interface.
+ * Please keep in sync with the Navigation interface requirements.
+ */
+const isValidNavigation = function(view: Navigation): boolean {
+	if (!view.id || typeof view.id !== 'string') {
+		throw new Error('Navigation id is required and must be a string')
+	}
+
+	if (!view.name || typeof view.name !== 'string') {
+		throw new Error('Navigation name is required and must be a string')
+	}
+
+	/**
+	 * Legacy handle their content and icon differently
+	 * TODO: remove when support for legacy views is removed
+	 */
+	if (!view.legacy) {
+		if (!view.getFiles || typeof view.getFiles !== 'function') {
+			throw new Error('Navigation getFiles is required and must be a function')
+		}
+
+		if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
+			throw new Error('Navigation icon is required and must be a valid svg string')
+		}
+	}
+
+	if (!('order' in view) || typeof view.order !== 'number') {
+		throw new Error('Navigation order is required and must be a number')
+	}
+
+	// Optional properties
+	if (view.columns) {
+		view.columns.forEach(isValidColumn)
+	}
+
+	if (view.emptyView && typeof view.emptyView !== 'function') {
+		throw new Error('Navigation emptyView must be a function')
+	}
+
+	if (view.parent && typeof view.parent !== 'string') {
+		throw new Error('Navigation parent must be a string')
+	}
+
+	if ('sticky' in view && typeof view.sticky !== 'boolean') {
+		throw new Error('Navigation sticky must be a boolean')
+	}
+
+	if ('expanded' in view && typeof view.expanded !== 'boolean') {
+		throw new Error('Navigation expanded must be a boolean')
+	}
+
+	return true
+}
+
+/**
+ * Typescript cannot validate an interface.
+ * Please keep in sync with the Column interface requirements.
+ */
+const isValidColumn = function(column: Column): boolean {
+	if (!column.id || typeof column.id !== 'string') {
+		throw new Error('Column id is required')
+	}
+
+	if (!column.title || typeof column.title !== 'string') {
+		throw new Error('Column title is required')
+	}
+
+	if (!column.property || typeof column.property !== 'string') {
+		throw new Error('Column property is required')
+	}
+
+	// Optional properties
+	if (column.sortFunction && typeof column.sortFunction !== 'function') {
+		throw new Error('Column sortFunction must be a function')
+	}
+
+	if (column.summary && typeof column.summary !== 'function') {
+		throw new Error('Column summary must be a function')
+	}
+
+	return true
+}

+ 156 - 0
apps/files/src/views/Navigation.vue

@@ -0,0 +1,156 @@
+<!--
+  - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+  -
+  - @author Gary Kim <gary@garykim.dev>
+  -
+  - @license GNU AGPL version 3 or any later version
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+  -->
+<template>
+	<NcAppNavigation>
+		<NcAppNavigationItem v-for="view in parentViews"
+			:key="view.id"
+			:allow-collapse="true"
+			:to="{name: 'filelist', params: { view: view.id }}"
+			:icon="view.iconClass"
+			:open="view.expanded"
+			:pinned="view.sticky"
+			:title="view.name"
+			@update:open="onToggleExpand(view)">
+			<NcAppNavigationItem v-for="child in childViews[view.id]"
+				:key="child.id"
+				:to="{name: 'filelist', params: { view: child.id }}"
+				:icon="child.iconClass"
+				:title="child.name" />
+		</NcAppNavigationItem>
+	</NcAppNavigation>
+</template>
+
+<script>
+import { emit } from '@nextcloud/event-bus'
+import { generateUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
+import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+
+import Navigation from '../services/Navigation.ts'
+import logger from '../logger.js'
+
+export default {
+	name: 'Navigation',
+
+	components: {
+		NcAppNavigation,
+		NcAppNavigationItem,
+	},
+
+	props: {
+		// eslint-disable-next-line vue/prop-name-casing
+		Navigation: {
+			type: Navigation,
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			key: 'value',
+		}
+	},
+
+	computed: {
+		currentViewId() {
+			return this.$route.params.view || 'files'
+		},
+		currentView() {
+			return this.views.find(view => view.id === this.currentViewId)
+		},
+
+		/** @return {Navigation[]} */
+		views() {
+			return this.Navigation.views
+		},
+		parentViews() {
+			return this.views
+				// filter child views
+				.filter(view => !view.parent)
+				// sort views by order
+				.sort((a, b) => {
+					return a.order - b.order
+				})
+		},
+		childViews() {
+			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
+					})
+					return list
+				}, {})
+		},
+	},
+
+	watch: {
+		currentView(view, oldView) {
+			logger.debug('View changed', { view })
+			this.showView(view, oldView)
+		},
+	},
+
+	beforeMount() {
+		if (this.currentView) {
+			logger.debug('Navigation mounted. Showing requested view', { view: this.currentView })
+			this.showView(this.currentView)
+		}
+	},
+
+	methods: {
+		/**
+		 * @param {Navigation} view the new active view
+		 * @param {Navigation} oldView the old active view
+		 */
+		showView(view, oldView) {
+			if (view.legacy) {
+				document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
+					el.classList.add('hidden')
+				})
+				document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer').classList.remove('hidden')
+			}
+			this.Navigation.setActive(view)
+			emit('files:view:changed', view)
+		},
+
+		onToggleExpand(view) {
+			// Invert state
+			view.expanded = !view.expanded
+			axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded })
+		},
+	},
+}
+</script>
+
+<style scoped lang="scss">
+// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in
+.app-navigation::v-deep .app-navigation-entry-icon {
+	background-repeat: no-repeat;
+	background-position: center;
+}
+</style>

+ 2 - 1
apps/files/templates/appnavigation.php

@@ -1,4 +1,5 @@
-<div id="app-navigation" role="navigation">
+<div id="app-navigation-files" role="navigation"></div>
+<div class="hidden">
 	<ul class="with-icon" tabindex="0">
 
 		<?php

+ 4 - 0
apps/files/templates/index.php

@@ -8,6 +8,10 @@
 	<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>"
 		title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label>
 
+	<!-- New files vue container -->
+	<div id="app-content-vue" class="hidden"></div>
+
+	<!-- Legacy views -->
 	<?php foreach ($_['appContents'] as $content) { ?>
 	<div id="app-content-<?php p($content['id']) ?>" class="hidden viewcontainer">
 	<?php print_unescaped($content['content']) ?>

+ 2 - 4
apps/files/tests/Controller/ViewControllerTest.php

@@ -248,8 +248,7 @@ class ViewControllerTest extends TestCase {
 						'quickaccesselement' => 'true',
 					],
 				],
-				'defaultExpandedState' => false,
-				'expandedState' => 'show_Quick_Access',
+				'expanded' => false,
 				'unread' => 0,
 			],
 			'systemtagsfilter' => [
@@ -323,8 +322,7 @@ class ViewControllerTest extends TestCase {
 				'active' => false,
 				'icon' => '',
 				'type' => 'link',
-				'expandedState' => 'show_sharing_menu',
-				'defaultExpandedState' => false,
+				'expanded' => false,
 				'unread' => 0,
 			]
 		]);

+ 0 - 1
apps/files_sharing/lib/AppInfo/Application.php

@@ -284,7 +284,6 @@ class Application extends App implements IBootstrap {
 				'name' => $l->t('Shares'),
 				'classes' => 'collapsible',
 				'sublist' => $sharingSublistArray,
-				'expandedState' => 'show_sharing_menu'
 			];
 		});
 	}

+ 2 - 0
babel.config.js

@@ -4,6 +4,8 @@ module.exports = {
 		'@babel/plugin-proposal-class-properties',
 	],
 	presets: [
+		// https://babeljs.io/docs/en/babel-preset-typescript
+		'@babel/preset-typescript',
 		[
 			'@babel/preset-env',
 			{

File diff suppressed because it is too large
+ 343 - 265
package-lock.json


+ 6 - 4
package.json

@@ -35,7 +35,7 @@
   "dependencies": {
     "@chenfengyuan/vue-qrcode": "^1.0.2",
     "@mdi/svg": "^7.0.96",
-    "@nextcloud/auth": "^1.3.0",
+    "@nextcloud/auth": "^2.0.0",
     "@nextcloud/axios": "^1.10.0",
     "@nextcloud/browser-storage": "^0.1.1",
     "@nextcloud/browserslist-config": "^2.3.0",
@@ -43,10 +43,10 @@
     "@nextcloud/capabilities": "^1.0.4",
     "@nextcloud/dialogs": "^4.0.0-beta.2",
     "@nextcloud/event-bus": "^3.0.2",
-    "@nextcloud/files": "^2.1.0",
-    "@nextcloud/initial-state": "^1.2.1",
+    "@nextcloud/files": "^3.0.0-beta.5",
+    "@nextcloud/initial-state": "^2.0.0",
     "@nextcloud/l10n": "^1.6.0",
-    "@nextcloud/logger": "^2.1.0",
+    "@nextcloud/logger": "^2.4.0",
     "@nextcloud/moment": "^1.2.1",
     "@nextcloud/password-confirmation": "^4.0.2",
     "@nextcloud/paths": "^2.1.0",
@@ -72,6 +72,7 @@
     "focus-visible": "^5.2.0",
     "handlebars": "^4.7.7",
     "ical.js": "^1.4.0",
+    "is-svg": "^4.3.2",
     "jquery": "~3.6",
     "jquery-migrate": "~3.4",
     "jquery-ui": "^1.13.2",
@@ -113,6 +114,7 @@
   },
   "devDependencies": {
     "@babel/node": "^7.20.0",
+    "@babel/preset-typescript": "^7.18.6",
     "@cypress/browserify-preprocessor": "^3.0.2",
     "@nextcloud/babel-config": "^1.0.0",
     "@nextcloud/cypress": "^1.0.0-beta.1",

+ 9 - 6
tsconfig.json

@@ -1,16 +1,19 @@
 {
 	"extends": "@vue/tsconfig/tsconfig.json",
-	"include": ["./**/*.ts"],
+	"include": ["./apps/**/*.ts", "./core/**/*.ts"],
 	"compilerOptions": {
 		"types": ["node"],
-		"allowSyntheticDefaultImports": true,
-		"moduleResolution": "node",
+		"outDir": "./dist/",
 		"target": "ESNext",
 		"module": "esnext",
-		"declaration": true,
-		"strict": true,
+		"moduleResolution": "node",
+		// Allow ts to import js files
+		"allowJs": true,
+		"allowSyntheticDefaultImports": true,
+		"declaration": false,
 		"noImplicitAny": false,
-		"resolveJsonModule": true
+		"resolveJsonModule": true,
+		"strict": true,
 	},
 	"ts-node": {
 		// these options are overrides used only by ts-node

+ 6 - 1
webpack.common.js

@@ -84,6 +84,11 @@ module.exports = {
 					'emoji-mart-vue-fast',
 				]),
 			},
+			{
+				test: /\.tsx?$/,
+				use: 'babel-loader',
+				exclude: BabelLoaderExcludeNodeModulesExcept([]),
+			},
 			{
 				test: /\.js$/,
 				loader: 'babel-loader',
@@ -163,7 +168,7 @@ module.exports = {
 			// make sure to use the handlebar runtime when importing
 			handlebars: 'handlebars/runtime',
 		},
-		extensions: ['*', '.js', '.vue'],
+		extensions: ['*', '.ts', '.js', '.vue'],
 		symlinks: true,
 		fallback: {
 			buffer: require.resolve('buffer'),

Some files were not shown because too many files changed in this diff