Skip to content

Commit

Permalink
fixup! feat(files): add uploader and new folder
Browse files Browse the repository at this point in the history
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
  • Loading branch information
skjnldsv committed Sep 1, 2023
1 parent c6be9a5 commit 991d0ed
Show file tree
Hide file tree
Showing 10 changed files with 168 additions and 42 deletions.
24 changes: 16 additions & 8 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}
},
},
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -753,17 +756,20 @@ 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')
return
}
input.setSelectionRange(0, length)
input.focus()
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions apps/files/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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'
Expand All @@ -27,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;
Expand Down
66 changes: 63 additions & 3 deletions apps/files/src/newMenu/newFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,77 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { type Entry, addNewFileMenuEntry, Permission, Folder, View } from '@nextcloud/files'
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<createFolderResponse> => {
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,
handler(context: Folder, view: View) {
console.debug(context, view)
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

Expand Down
54 changes: 32 additions & 22 deletions apps/files/src/services/Files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -73,30 +74,39 @@ const resultToNode = function(node: FileStat): File | Folder {
: new Folder(nodeData)
}

export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
export const getContents = (path = '/'): Promise<ContentsWithRoot> => {
const controller = new AbortController()
const propfindPayload = getDefaultPropfind()

const contentsResponse = await client.getDirectoryContents(path, {
details: true,
data: propfindPayload,
includeSelf: true,
}) as ResponseDataDetailed<FileStat[]>
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<FileStat[]>

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)
}
})
}
6 changes: 5 additions & 1 deletion apps/files/src/store/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion apps/files/src/store/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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', {
Expand Down Expand Up @@ -50,14 +53,27 @@ 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,
})
},
},
})

const pathsStore = store(...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)

Expand Down
2 changes: 2 additions & 0 deletions apps/files/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*
*/
import type { Folder, Node } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'

// Global definitions
export type Service = string
Expand Down Expand Up @@ -103,4 +104,5 @@ export interface RenamingStore {

// Uploader store
export interface UploaderStore {
queue: Upload[]
}
28 changes: 25 additions & 3 deletions apps/files/src/views/FilesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<template #actions>
<!-- Uploader -->
<UploadPicker v-if="currentFolder"
:content="dirContents"
:destination="currentFolder"
:multiple="true"
@uploaded="onUpload" />
Expand Down Expand Up @@ -72,9 +73,12 @@
<script lang="ts">
import type { Route } from 'vue-router'
import type { Upload } from '@nextcloud/upload'
import type { UserConfig } from '../types.ts'
import type { View, ContentsWithRoot } from '@nextcloud/files'
import { Folder, Node, type View, type ContentsWithRoot, join } from 'path'
import { Folder, Node } from '@nextcloud/files'
import { join, dirname } from 'path'
import { orderBy } from 'natural-orderby'
import { translate } from '@nextcloud/l10n'
import { UploadPicker } from '@nextcloud/upload'
Expand All @@ -88,6 +92,7 @@ import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUploaderStore } from '../store/uploader.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
Expand Down Expand Up @@ -117,12 +122,14 @@ export default Vue.extend({
const filesStore = useFilesStore()
const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
const uploaderStore = useUploaderStore()
const userConfigStore = useUserConfigStore()
const viewConfigStore = useViewConfigStore()
return {
filesStore,
pathsStore,
selectionStore,
uploaderStore,
userConfigStore,
viewConfigStore,
}
Expand Down Expand Up @@ -283,6 +290,7 @@ export default Vue.extend({
this.filesStore.updateNodes(contents)
// Define current directory children
// TODO: make it more official
folder._children = contents.map(node => node.fileid)
// If we're in the root dir, define the root
Expand Down Expand Up @@ -322,8 +330,22 @@ export default Vue.extend({
return this.filesStore.getNode(fileId)
},
onUpload(...args) {
console.debug(args)
/**
* The upload manager have finished handling the queue
* @param {Upload} upload the uploaded data
*/
onUpload(upload: Upload) {
// Let's only refresh the current Folder
// Navigating to a different folder will refresh it anyway
const destinationSource = dirname(upload.source)
const needsRefresh = destinationSource === this.currentFolder?.source
// TODO: fetch uploaded files data only
// Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
if (needsRefresh) {
// fetchContent will cancel the previous ongoing promise
this.fetchContent()
}
},
t: translate,
Expand Down
Loading

0 comments on commit 991d0ed

Please sign in to comment.