From e87bd167931d5717a25d3c35e808f657ec2b4001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= Date: Thu, 17 Aug 2023 20:00:51 +0200 Subject: [PATCH] feat(files): add uploader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ --- apps/files/src/components/BreadCrumbs.vue | 5 + apps/files/src/components/FileEntry.vue | 24 ++- apps/files/src/main.ts | 5 + apps/files/src/newMenu/newFolder.ts | 96 ++++++++++++ apps/files/src/services/Files.ts | 54 ++++--- apps/files/src/store/files.ts | 6 +- apps/files/src/store/paths.ts | 18 ++- apps/files/src/store/uploader.ts | 32 ++++ apps/files/src/types.ts | 6 + apps/files/src/views/FilesList.vue | 42 ++++- lib/private/Preview/MimeIconProvider.php | 2 +- package-lock.json | 178 +++++++++++++++++++++- package.json | 3 +- 13 files changed, 430 insertions(+), 41 deletions(-) create mode 100644 apps/files/src/newMenu/newFolder.ts create mode 100644 apps/files/src/store/uploader.ts diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index c2938c5aca2ba..ec2041bf8ad1f 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -11,6 +11,11 @@ + + + diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 216d6bf2cd591..fce8b7ed263dc 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -171,7 +171,7 @@ import { debounce } from 'debounce' import { emit } from '@nextcloud/event-bus' import { extname } from 'path' import { generateUrl } from '@nextcloud/router' -import { getFileActions, DefaultType, FileType, formatFileSize, Permission } from '@nextcloud/files' +import { getFileActions, DefaultType, FileType, formatFileSize, Permission, NodeStatus } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' import { vOnClickOutside } from '@vueuse/components' @@ -521,8 +521,10 @@ export default Vue.extend({ * If renaming starts, select the file name * in the input, without the extension. */ - isRenaming() { - this.startRenaming() + isRenaming(renaming) { + if (renaming) { + this.startRenaming() + } }, }, @@ -718,9 +720,10 @@ export default Vue.extend({ * input validity using browser's native validation. * @param event the keyup event */ - checkInputValidity(event: KeyboardEvent) { - const input = event?.target as HTMLInputElement + checkInputValidity(event?: KeyboardEvent) { + const input = event.target as HTMLInputElement const newName = this.newName.trim?.() || '' + logger.debug('Checking input validity', { newName }) try { this.isFileNameValid(newName) input.setCustomValidity('') @@ -753,10 +756,10 @@ export default Vue.extend({ }, startRenaming() { - this.checkInputValidity() this.$nextTick(() => { - const extLength = (this.source.extension || '').length - const length = this.source.basename.length - extLength + // Using split to get the true string length + const extLength = (this.source.extension || '').split('').length + const length = this.source.basename.split('').length - extLength const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input if (!input) { logger.error('Could not find the rename input') @@ -764,6 +767,9 @@ export default Vue.extend({ } input.setSelectionRange(0, length) input.focus() + + // Trigger a keyup event to update the input validity + input.dispatchEvent(new Event('keyup')) }) }, stopRenaming() { @@ -816,6 +822,8 @@ export default Vue.extend({ emit('files:node:updated', this.source) emit('files:node:renamed', this.source) showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) + + // Reset the renaming store this.stopRenaming() this.$nextTick(() => { this.$refs.basename.focus() diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 593baa493236e..8bcfacf953ad9 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -10,10 +10,12 @@ import './actions/openInFilesAction.js' import './actions/renameAction' import './actions/sidebarAction' import './actions/viewInFolderAction' +import './newMenu/newFolder' import Vue from 'vue' import { createPinia, PiniaVuePlugin } from 'pinia' import { getNavigation } from '@nextcloud/files' +import { getRequestToken } from '@nextcloud/auth' import FilesListView from './views/FilesList.vue' import NavigationView from './views/Navigation.vue' @@ -26,6 +28,9 @@ import RouterService from './services/RouterService' import SettingsModel from './models/Setting.js' import SettingsService from './services/Settings.js' +// @ts-expect-error __webpack_nonce__ is injected by webpack +__webpack_nonce__ = btoa(getRequestToken()) + declare global { interface Window { OC: any; diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts new file mode 100644 index 0000000000000..399e6c1649ae1 --- /dev/null +++ b/apps/files/src/newMenu/newFolder.ts @@ -0,0 +1,96 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import type { Entry, Node } from '@nextcloud/files' + +import { addNewFileMenuEntry, Permission, Folder } from '@nextcloud/files' +import { basename, extname } from 'path' +import { emit } from '@nextcloud/event-bus' +import { getCurrentUser } from '@nextcloud/auth' +import { showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw' +import Vue from 'vue' + +type createFolderResponse = { + fileid: number + source: string +} + +const createNewFolder = async (root: string, name: string): Promise => { + const source = root + '/' + name + const response = await axios({ + method: 'MKCOL', + url: source, + headers: { + Overwrite: 'F', + }, + }) + return { + fileid: parseInt(response.headers['oc-fileid']), + source, + } +} + +// TODO: move to @nextcloud/files +export const getUniqueName = (name: string, names: string[]): string => { + let newName = name + let i = 1 + while (names.includes(newName)) { + const ext = extname(name) + newName = `${basename(name, ext)} (${i++})${ext}` + } + return newName +} + +const entry = { + id: 'newFolder', + displayName: t('files', 'New folder'), + if: (context: Folder) => (context.permissions & Permission.CREATE) !== 0, + iconSvgInline: FolderPlusSvg, + async handler(context: Folder, content: Node[]) { + const contentNames = content.map((node: Node) => node.basename) + const name = getUniqueName(t('files', 'New Folder'), contentNames) + const { fileid, source } = await createNewFolder(context.source, name) + + // Create the folder in the store + const folder = new Folder({ + source, + id: fileid, + mtime: new Date(), + owner: getCurrentUser()?.uid || null, + permissions: Permission.ALL, + root: context?.root || '/files/' + getCurrentUser()?.uid, + }) + + if (!context._children) { + Vue.set(context, '_children', []) + } + context._children.push(folder.fileid) + + showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) })) + emit('files:node:created', folder) + emit('files:node:rename', folder) + }, +} as Entry + +addNewFileMenuEntry(entry) diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index 93325decc9cd9..d392dbb775140 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -22,6 +22,7 @@ import type { ContentsWithRoot } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed, DAVResultResponseProps } from 'webdav' +import { cancelable, CancelablePromise } from 'cancelable-promise' import { File, Folder, davParsePermissions } from '@nextcloud/files' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' @@ -73,30 +74,39 @@ const resultToNode = function(node: FileStat): File | Folder { : new Folder(nodeData) } -export const getContents = async (path = '/'): Promise => { +export const getContents = (path = '/'): Promise => { + const controller = new AbortController() const propfindPayload = getDefaultPropfind() - const contentsResponse = await client.getDirectoryContents(path, { - details: true, - data: propfindPayload, - includeSelf: true, - }) as ResponseDataDetailed + return new CancelablePromise(async (resolve, reject, onCancel) => { + onCancel(() => controller.abort()) + try { + const contentsResponse = await client.getDirectoryContents(path, { + details: true, + data: propfindPayload, + includeSelf: true, + signal: controller.signal, + }) as ResponseDataDetailed - const root = contentsResponse.data[0] - const contents = contentsResponse.data.slice(1) - if (root.filename !== path) { - throw new Error('Root node does not match requested path') - } - - return { - folder: resultToNode(root) as Folder, - contents: contents.map(result => { - try { - return resultToNode(result) - } catch (error) { - logger.error(`Invalid node detected '${result.basename}'`, { error }) - return null + const root = contentsResponse.data[0] + const contents = contentsResponse.data.slice(1) + if (root.filename !== path) { + throw new Error('Root node does not match requested path') } - }).filter(Boolean) as File[], - } + + resolve({ + folder: resultToNode(root) as Folder, + contents: contents.map(result => { + try { + return resultToNode(result) + } catch (error) { + logger.error(`Invalid node detected '${result.basename}'`, { error }) + return null + } + }).filter(Boolean) as File[], + }) + } catch (error) { + reject(error) + } + }) } diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index c36ebcfecc29a..8653cc8e44907 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -83,13 +83,17 @@ export const useFilesStore = function(...args) { onDeletedNode(node: Node) { this.deleteNodes([node]) }, + + onCreatedNode(node: Node) { + this.updateNodes([node]) + }, }, }) const fileStore = store(...args) // Make sure we only register the listeners once if (!fileStore._initialized) { - // subscribe('files:node:created', fileStore.onCreatedNode) + subscribe('files:node:created', fileStore.onCreatedNode) subscribe('files:node:deleted', fileStore.onDeletedNode) // subscribe('files:node:moved', fileStore.onMovedNode) // subscribe('files:node:updated', fileStore.onUpdatedNode) diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts index 6164e6644981e..1b86c69ac5785 100644 --- a/apps/files/src/store/paths.ts +++ b/apps/files/src/store/paths.ts @@ -19,9 +19,12 @@ * along with this program. If not, see . * */ +import { Node, getNavigation } from '@nextcloud/files' import type { FileId, PathsStore, PathOptions, ServicesState } from '../types' import { defineStore } from 'pinia' import Vue from 'vue' +import logger from '../logger' +import { subscribe } from '@nextcloud/event-bus' export const usePathsStore = function(...args) { const store = defineStore('paths', { @@ -50,6 +53,19 @@ export const usePathsStore = function(...args) { // Now we can set the provided path Vue.set(this.paths[payload.service], payload.path, payload.fileid) }, + + onCreatedNode(node: Node) { + const currentView = getNavigation().active + if (!node.fileid) { + logger.error('Node has no fileid', { node }) + return + } + this.addPath({ + service: currentView?.id || 'files', + path: node.path, + fileid: node.fileid, + }) + }, }, }) @@ -57,7 +73,7 @@ export const usePathsStore = function(...args) { // Make sure we only register the listeners once if (!pathsStore._initialized) { // TODO: watch folders to update paths? - // subscribe('files:node:created', pathsStore.onCreatedNode) + subscribe('files:node:created', pathsStore.onCreatedNode) // subscribe('files:node:deleted', pathsStore.onDeletedNode) // subscribe('files:node:moved', pathsStore.onMovedNode) diff --git a/apps/files/src/store/uploader.ts b/apps/files/src/store/uploader.ts new file mode 100644 index 0000000000000..ce31cdb47b763 --- /dev/null +++ b/apps/files/src/store/uploader.ts @@ -0,0 +1,32 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @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 . + * + */ +import { defineStore } from 'pinia' +import type { UploaderStore } from '../types' + +import { getUploader } from '@nextcloud/upload' +const uploader = getUploader() + +export const useUploaderStore = defineStore('uploader', { + state: () => ({ + queue: uploader.queue, + } as UploaderStore), +}) diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index 8035d9dc19855..bf9f3a09648e5 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -20,6 +20,7 @@ * */ import type { Folder, Node } from '@nextcloud/files' +import type { Upload } from '@nextcloud/upload' // Global definitions export type Service = string @@ -100,3 +101,8 @@ export interface RenamingStore { renamingNode?: Node newName: string } + +// Uploader store +export interface UploaderStore { + queue: Upload[] +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index 67dffb8773e1f..fbd483fd23f94 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -23,7 +23,16 @@
- + + + @@ -64,11 +73,15 @@