aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/FilesApp.vue25
-rw-r--r--apps/files/src/actions/deleteAction.spec.ts81
-rw-r--r--apps/files/src/actions/deleteAction.ts93
-rw-r--r--apps/files/src/components/BreadCrumbs.vue11
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue52
-rw-r--r--apps/files/src/components/FileEntry.vue339
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue26
-rw-r--r--apps/files/src/components/FileEntry/FileEntryCheckbox.vue11
-rw-r--r--apps/files/src/components/FileEntryGrid.vue305
-rw-r--r--apps/files/src/components/FileEntryMixin.ts388
-rw-r--r--apps/files/src/components/FilesListTableHeader.vue13
-rw-r--r--apps/files/src/components/FilesListTableHeaderActions.vue13
-rw-r--r--apps/files/src/components/FilesListVirtual.vue54
-rw-r--r--apps/files/src/components/NavigationQuota.vue18
-rw-r--r--apps/files/src/components/NewNodeDialog.vue149
-rw-r--r--apps/files/src/components/VirtualList.vue15
-rw-r--r--apps/files/src/init-templates.ts149
-rw-r--r--apps/files/src/init.ts8
-rw-r--r--apps/files/src/main.ts24
-rw-r--r--apps/files/src/newMenu/newFolder.ts40
-rw-r--r--apps/files/src/newMenu/newFromTemplate.ts88
-rw-r--r--apps/files/src/newMenu/newTemplatesFolder.ts100
-rw-r--r--apps/files/src/services/DropService.ts2
-rw-r--r--apps/files/src/services/Files.ts9
-rw-r--r--apps/files/src/store/userconfig.ts5
-rw-r--r--apps/files/src/types.ts1
-rw-r--r--apps/files/src/utils/newNodeDialog.ts57
-rw-r--r--apps/files/src/views/FilesList.vue23
-rw-r--r--apps/files/src/views/Navigation.cy.ts36
-rw-r--r--apps/files/src/views/Navigation.vue21
-rw-r--r--apps/files/src/views/Settings.vue4
-rw-r--r--apps/files/src/views/Sidebar.vue25
-rw-r--r--apps/files/src/views/TemplatePicker.vue68
33 files changed, 1257 insertions, 996 deletions
diff --git a/apps/files/src/FilesApp.vue b/apps/files/src/FilesApp.vue
new file mode 100644
index 00000000000..a2a7f495c09
--- /dev/null
+++ b/apps/files/src/FilesApp.vue
@@ -0,0 +1,25 @@
+<template>
+ <NcContent app-name="files">
+ <Navigation />
+ <FilesList />
+ </NcContent>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+
+import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
+
+import Navigation from './views/Navigation.vue'
+import FilesList from './views/FilesList.vue'
+
+export default defineComponent({
+ name: 'FilesApp',
+
+ components: {
+ NcContent,
+ FilesList,
+ Navigation,
+ },
+})
+</script>
diff --git a/apps/files/src/actions/deleteAction.spec.ts b/apps/files/src/actions/deleteAction.spec.ts
index c568ec59d9d..0adb302dc32 100644
--- a/apps/files/src/actions/deleteAction.spec.ts
+++ b/apps/files/src/actions/deleteAction.spec.ts
@@ -22,9 +22,9 @@
import { action } from './deleteAction'
import { expect } from '@jest/globals'
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
-import * as auth from '@nextcloud/auth'
import * as eventBus from '@nextcloud/event-bus'
import axios from '@nextcloud/axios'
+
import logger from '../logger'
const view = {
@@ -50,36 +50,81 @@ describe('Delete action conditions tests', () => {
permissions: Permission.ALL,
})
- // const file2 = new File({
- // id: 1,
- // source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
- // owner: 'admin',
- // mime: 'text/plain',
- // permissions: Permission.ALL,
- // })
+ const file2 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'shared',
+ },
+ })
+
+ const folder = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ const folder2 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'shared',
+ },
+ })
+
+ const folder3 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ attributes: {
+ 'is-mount-root': true,
+ 'mount-type': 'external',
+ },
+ })
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('delete')
- expect(action.displayName([file], view)).toBe('Delete')
+ expect(action.displayName([file], view)).toBe('Delete file')
expect(action.iconSvgInline([], view)).toBe('<svg>SvgMock</svg>')
expect(action.default).toBeUndefined()
expect(action.order).toBe(100)
})
- test('Default trashbin view values', () => {
+ test('Default folder displayName', () => {
+ expect(action.displayName([folder], view)).toBe('Delete folder')
+ })
+
+ test('Default trashbin view displayName', () => {
expect(action.displayName([file], trashbinView)).toBe('Delete permanently')
})
- // TODO: Fix this test
- // test('Shared node values', () => {
- // jest.spyOn(auth, 'getCurrentUser').mockReturnValue(null)
- // expect(action.displayName([file2], view)).toBe('Unshare')
- // })
+ test('Shared root node displayName', () => {
+ expect(action.displayName([file2], view)).toBe('Leave this share')
+ expect(action.displayName([folder2], view)).toBe('Leave this share')
+ expect(action.displayName([file2, folder2], view)).toBe('Leave these shares')
+ })
+
+ test('External storage root node displayName', () => {
+ expect(action.displayName([folder3], view)).toBe('Disconnect storage')
+ expect(action.displayName([folder3, folder3], view)).toBe('Disconnect storages')
+ })
- // test('Shared and owned nodes values', () => {
- // expect(action.displayName([file, file2], view)).toBe('Delete and unshare')
- // })
+ test('Shared and owned nodes displayName', () => {
+ expect(action.displayName([file, file2], view)).toBe('Delete and unshare')
+ })
})
describe('Delete action enabled tests', () => {
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
index 1bc07aaa6f9..a086eb2e666 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -20,21 +20,102 @@
*
*/
import { emit } from '@nextcloud/event-bus'
-import { Permission, Node, View, FileAction } from '@nextcloud/files'
-import { translate as t } from '@nextcloud/l10n'
+import { Permission, Node, View, FileAction, FileType } from '@nextcloud/files'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
+
+import CloseSvg from '@mdi/svg/svg/close.svg?raw'
+import NetworkOffSvg from '@mdi/svg/svg/network-off.svg?raw'
import TrashCanSvg from '@mdi/svg/svg/trash-can.svg?raw'
import logger from '../logger.js'
+const canUnshareOnly = (nodes: Node[]) => {
+ return nodes.every(node => node.attributes['is-mount-root'] === true
+ && node.attributes['mount-type'] === 'shared')
+}
+
+const canDisconnectOnly = (nodes: Node[]) => {
+ return nodes.every(node => node.attributes['is-mount-root'] === true
+ && node.attributes['mount-type'] === 'external')
+}
+
+const isMixedUnshareAndDelete = (nodes: Node[]) => {
+ if (nodes.length === 1) {
+ return false
+ }
+
+ const hasSharedItems = nodes.some(node => canUnshareOnly([node]))
+ const hasDeleteItems = nodes.some(node => !canUnshareOnly([node]))
+ return hasSharedItems && hasDeleteItems
+}
+
+const isAllFiles = (nodes: Node[]) => {
+ return !nodes.some(node => node.type !== FileType.File)
+}
+
+const isAllFolders = (nodes: Node[]) => {
+ return !nodes.some(node => node.type !== FileType.Folder)
+}
+
export const action = new FileAction({
id: 'delete',
displayName(nodes: Node[], view: View) {
- return view.id === 'trashbin'
- ? t('files', 'Delete permanently')
- : t('files', 'Delete')
+ /**
+ * If we're in the trashbin, we can only delete permanently
+ */
+ if (view.id === 'trashbin') {
+ return t('files', 'Delete permanently')
+ }
+
+ /**
+ * If we're in the sharing view, we can only unshare
+ */
+ if (isMixedUnshareAndDelete(nodes)) {
+ return t('files', 'Delete and unshare')
+ }
+
+ /**
+ * If those nodes are all the root node of a
+ * share, we can only unshare them.
+ */
+ if (canUnshareOnly(nodes)) {
+ return n('files', 'Leave this share', 'Leave these shares', nodes.length)
+ }
+
+ /**
+ * If those nodes are all the root node of an
+ * external storage, we can only disconnect it.
+ */
+ if (canDisconnectOnly(nodes)) {
+ return n('files', 'Disconnect storage', 'Disconnect storages', nodes.length)
+ }
+
+ /**
+ * If we're only selecting files, use proper wording
+ */
+ if (isAllFiles(nodes)) {
+ return n('files', 'Delete file', 'Delete files', nodes.length)
+ }
+
+ /**
+ * If we're only selecting folders, use proper wording
+ */
+ if (isAllFolders(nodes)) {
+ return n('files', 'Delete folder', 'Delete folders', nodes.length)
+ }
+
+ return t('files', 'Delete')
},
- iconSvgInline: () => {
+ iconSvgInline: (nodes: Node[]) => {
+ if (canUnshareOnly(nodes)) {
+ return CloseSvg
+ }
+
+ if (canDisconnectOnly(nodes)) {
+ return NetworkOffSvg
+ }
+
return TrashCanSvg
},
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index bc177fb1989..eb90fac71f8 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -8,11 +8,13 @@
v-bind="section"
dir="auto"
:to="section.to"
+ :force-icon-text="true"
:title="titleForSection(index, section)"
:aria-description="ariaForSection(section)"
@click.native="onClick(section.to)">
<template v-if="index === 0" #icon>
- <Home :size="20"/>
+ <NcIconSvgWrapper v-if="section.icon" :size="20" :svg="section.icon" />
+ <Home v-else :size="20"/>
</template>
</NcBreadcrumb>
@@ -24,11 +26,12 @@
</template>
<script>
-import { translate as t} from '@nextcloud/l10n'
import { basename } from 'path'
+import { translate as t } from '@nextcloud/l10n'
import Home from 'vue-material-design-icons/Home.vue'
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
@@ -41,6 +44,7 @@ export default Vue.extend({
Home,
NcBreadcrumbs,
NcBreadcrumb,
+ NcIconSvgWrapper,
},
props: {
@@ -81,6 +85,7 @@ export default Vue.extend({
exact: true,
name: this.getDirDisplayName(dir),
to,
+ icon: this.$navigation.active?.icon || null,
}
})
},
@@ -95,7 +100,7 @@ export default Vue.extend({
},
getDirDisplayName(path) {
if (path === '/') {
- return t('files', 'Home')
+ return this.$navigation?.active?.name || t('files', 'Home')
}
const fileId = this.getFileIdFromPath(path)
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index 22de0f662de..a9c1d8e99ad 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -47,6 +47,7 @@ import { defineComponent } from 'vue'
import { Folder, Permission } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
+import { UploadStatus } from '@nextcloud/upload'
import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
@@ -126,7 +127,7 @@ export default defineComponent({
// only when we're leaving the current element
// Avoid flickering
const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) {
return
}
@@ -143,10 +144,11 @@ export default defineComponent({
}
},
- onDrop(event: DragEvent) {
- logger.debug('Dropped on DragAndDropNotice', { event, error: this.cantUploadLabel })
+ async onDrop(event: DragEvent) {
+ logger.debug('Dropped on DragAndDropNotice', { event })
- if (!this.canUpload || this.isQuotaExceeded) {
+ // cantUploadLabel is null if we can upload
+ if (this.cantUploadLabel) {
showError(this.cantUploadLabel)
return
}
@@ -162,23 +164,31 @@ export default defineComponent({
// Start upload
logger.debug(`Uploading files to ${this.currentFolder.path}`)
// Process finished uploads
- handleDrop(event.dataTransfer).then((uploads) => {
- logger.debug('Upload terminated', { uploads })
- showSuccess(t('files', 'Upload successful'))
-
- // Scroll to last upload in current directory if terminated
- const lastUpload = uploads.findLast((upload) => !upload.file.webkitRelativePath.includes('/') && upload.response?.headers?.['oc-fileid'])
- if (lastUpload !== undefined) {
- this.$router.push({
- ...this.$route,
- params: {
- view: this.$route.params?.view ?? 'files',
- // Remove instanceid from header response
- fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
- },
- })
- }
- })
+ const uploads = await handleDrop(event.dataTransfer)
+ logger.debug('Upload terminated', { uploads })
+
+ if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) {
+ showError(t('files', 'Some files could not be uploaded'))
+ const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED)
+ logger.debug('Some files could not be uploaded', { failedUploads })
+ } else {
+ showSuccess(t('files', 'Files uploaded successfully'))
+ }
+
+ // Scroll to last successful upload in current directory if terminated
+ const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED
+ && !upload.file.webkitRelativePath.includes('/')
+ && upload.response?.headers?.['oc-fileid'])
+
+ if (lastUpload !== undefined) {
+ this.$router.push({
+ ...this.$route,
+ params: {
+ view: this.$route.params?.view ?? 'files',
+ fileid: parseInt(lastUpload.response!.headers['oc-fileid']),
+ },
+ })
+ }
}
this.dragover = false
},
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 8b4c7b71ef9..973e1de667f 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -21,7 +21,11 @@
-->
<template>
- <tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+ <tr :class="{
+ 'files-list__row--dragover': dragover,
+ 'files-list__row--loading': isLoading,
+ 'files-list__row--active': isActive,
+ }"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
@@ -96,37 +100,23 @@
</template>
<script lang="ts">
-import type { PropType } from 'vue'
-
-import { extname, join } from 'path'
-import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
-import { getUploader } from '@nextcloud/upload'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { vOnClickOutside } from '@vueuse/components'
+import { defineComponent } from 'vue'
+import { Permission, formatFileSize } from '@nextcloud/files'
import moment from '@nextcloud/moment'
-import { generateUrl } from '@nextcloud/router'
-import Vue, { defineComponent } from 'vue'
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getDragAndDropPreview } from '../utils/dragUtils.ts'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
-import { hashCode } from '../utils/hashUtils.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
+
+import FileEntryMixin from './FileEntryMixin.ts'
import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
import CustomElementRender from './CustomElementRender.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
-import logger from '../logger.js'
-
-Vue.directive('onClickOutside', vOnClickOutside)
export default defineComponent({
name: 'FileEntry',
@@ -140,6 +130,10 @@ export default defineComponent({
NcDateTime,
},
+ mixins: [
+ FileEntryMixin,
+ ],
+
props: {
isMtimeAvailable: {
type: Boolean,
@@ -149,18 +143,6 @@ export default defineComponent({
type: Boolean,
default: false,
},
- source: {
- type: [Folder, NcFile, Node] as PropType<Node>,
- required: true,
- },
- nodes: {
- type: Array as PropType<Node[]>,
- required: true,
- },
- filesListWidth: {
- type: Number,
- default: 0,
- },
compact: {
type: Boolean,
default: false,
@@ -182,13 +164,6 @@ export default defineComponent({
}
},
- data() {
- return {
- loading: '',
- dragover: false,
- }
- },
-
computed: {
/**
* Conditionally add drag and drop listeners
@@ -210,9 +185,6 @@ export default defineComponent({
drop: this.onDrop,
}
},
- currentView(): View {
- return this.$navigation.active as View
- },
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512 || this.compact) {
@@ -221,42 +193,10 @@ export default defineComponent({
return this.currentView?.columns || []
},
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentFileId() {
- return this.$route.params?.fileid || this.$route.query?.fileid || null
- },
- fileid() {
- return this.source?.fileid?.toString?.()
- },
- uniqueId() {
- return hashCode(this.source.source)
- },
- isLoading() {
- return this.source.status === NodeStatus.LOADING
- },
-
- extension() {
- if (this.source.attributes?.displayName) {
- return extname(this.source.attributes.displayName)
- }
- return this.source.extension || ''
- },
- displayName() {
- const ext = this.extension
- const name = (this.source.attributes.displayName
- || this.source.basename)
-
- // Strip extension from name if defined
- return !ext ? name : name.slice(0, 0 - ext.length)
- },
-
size() {
const size = parseInt(this.source.size, 10) || 0
if (typeof size !== 'number' || size < 0) {
- return t('files', 'Pending')
+ return this.t('files', 'Pending')
}
return formatFileSize(size, true)
},
@@ -297,260 +237,15 @@ export default defineComponent({
return ''
},
- draggingFiles() {
- return this.draggingStore.dragging
- },
- selectedFiles() {
- return this.selectionStore.selected
- },
- isSelected() {
- return this.selectedFiles.includes(this.fileid)
- },
-
- isRenaming() {
- return this.renamingStore.renamingNode === this.source
- },
- isRenamingSmallScreen() {
- return this.isRenaming && this.filesListWidth < 512
- },
-
- isActive() {
- return this.fileid === this.currentFileId?.toString?.()
- },
-
- canDrag() {
- if (this.isRenaming) {
- return false
- }
-
- const canDrag = (node: Node): boolean => {
- return (node?.permissions & Permission.UPDATE) !== 0
- }
-
- // If we're dragging a selection, we need to check all files
- if (this.selectedFiles.length > 0) {
- const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- return nodes.every(canDrag)
- }
- return canDrag(this.source)
- },
-
- canDrop() {
- if (this.source.type !== FileType.Folder) {
- return false
- }
-
- // If the current folder is also being dragged, we can't drop it on itself
- if (this.draggingFiles.includes(this.fileid)) {
- return false
- }
-
- return (this.source.permissions & Permission.CREATE) !== 0
- },
-
- openedMenu: {
- get() {
- return this.actionsMenuStore.opened === this.uniqueId
- },
- set(opened) {
- // Only reset when opening a new menu
- if (opened) {
- // Reset any right click position override on close
- // Wait for css animation to be done
- const root = this.$root.$el as HTMLElement
- root.style.removeProperty('--mouse-pos-x')
- root.style.removeProperty('--mouse-pos-y')
- }
-
- this.actionsMenuStore.opened = opened ? this.uniqueId : null
- },
- },
- },
-
- watch: {
/**
- * When the source changes, reset the preview
- * and fetch the new one.
+ * This entry is the current active node
*/
- source() {
- this.resetState()
+ isActive() {
+ return this.fileid === this.currentFileId?.toString?.()
},
},
- beforeDestroy() {
- this.resetState()
- },
-
methods: {
- resetState() {
- // Reset loading state
- this.loading = ''
-
- this.$refs.preview.reset()
-
- // Close menu
- this.openedMenu = false
- },
-
- // Open the actions menu on right click
- onRightClick(event) {
- // If already opened, fallback to default browser
- if (this.openedMenu) {
- return
- }
-
- const root = this.$root.$el as HTMLElement
- const contentRect = root.getBoundingClientRect()
- // Using Math.min/max to prevent the menu from going out of the AppContent
- // 200 = max width of the menu
- root.style.setProperty('--mouse-pos-x', Math.max(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px')
- root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px')
-
- // If the clicked row is in the selection, open global menu
- const isMoreThanOneSelected = this.selectedFiles.length > 1
- this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
-
- // Prevent any browser defaults
- event.preventDefault()
- event.stopPropagation()
- },
-
- execDefaultAction(event) {
- if (event.ctrlKey || event.metaKey) {
- event.preventDefault()
- window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
- return false
- }
-
- this.$refs.actions.execDefaultAction(event)
- },
-
- openDetailsIfAvailable(event) {
- event.preventDefault()
- event.stopPropagation()
- if (sidebarAction?.enabled?.([this.source], this.currentView)) {
- sidebarAction.exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- onDragOver(event: DragEvent) {
- this.dragover = this.canDrop
- if (!this.canDrop) {
- event.dataTransfer.dropEffect = 'none'
- return
- }
-
- // Handle copy/move drag and drop
- if (event.ctrlKey) {
- event.dataTransfer.dropEffect = 'copy'
- } else {
- event.dataTransfer.dropEffect = 'move'
- }
- },
- onDragLeave(event: DragEvent) {
- // Counter bubbling, make sure we're ending the drag
- // only when we're leaving the current element
- const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
- return
- }
-
- this.dragover = false
- },
-
- async onDragStart(event: DragEvent) {
- event.stopPropagation()
- if (!this.canDrag) {
- event.preventDefault()
- event.stopPropagation()
- return
- }
-
- logger.debug('Drag started', { event })
-
- // Make sure that we're not dragging a file like the preview
- event.dataTransfer?.clearData?.()
-
- // Reset any renaming
- this.renamingStore.$reset()
-
- // Dragging set of files, if we're dragging a file
- // that is already selected, we use the entire selection
- if (this.selectedFiles.includes(this.fileid)) {
- this.draggingStore.set(this.selectedFiles)
- } else {
- this.draggingStore.set([this.fileid])
- }
-
- const nodes = this.draggingStore.dragging
- .map(fileid => this.filesStore.getNode(fileid)) as Node[]
-
- const image = await getDragAndDropPreview(nodes)
- event.dataTransfer?.setDragImage(image, -10, -10)
- },
- onDragEnd() {
- this.draggingStore.reset()
- this.dragover = false
- logger.debug('Drag ended')
- },
-
- async onDrop(event: DragEvent) {
- // skip if native drop like text drag and drop from files names
- if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
- return
- }
-
- event.preventDefault()
- event.stopPropagation()
-
- // If another button is pressed, cancel it
- // This allows cancelling the drag with the right click
- if (!this.canDrop || event.button !== 0) {
- return
- }
-
- const isCopy = event.ctrlKey
- this.dragover = false
-
- logger.debug('Dropped', { event, selection: this.draggingFiles })
-
- // Check whether we're uploading files
- if (event.dataTransfer?.files?.length > 0) {
- const uploader = getUploader()
- event.dataTransfer.files.forEach((file: File) => {
- uploader.upload(join(this.source.path, file.name), file)
- })
- logger.debug(`Uploading files to ${this.source.path}`)
- return
- }
-
- const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- nodes.forEach(async (node: Node) => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- try {
- // TODO: resolve potential conflicts prior and force overwrite
- await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
- } catch (error) {
- logger.error('Error while moving file', { error })
- if (isCopy) {
- showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
- } else {
- showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
- }
- } finally {
- Vue.set(node, 'status', undefined)
- }
- })
-
- // Reset selection after we dropped the files
- // if the dropped files are within the selection
- if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
- logger.debug('Dropped selection, resetting select store...')
- this.selectionStore.reset()
- }
- },
-
- t,
formatFileSize,
},
})
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index 1e453fec706..86689cfe62b 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -35,7 +35,6 @@
<NcActions ref="actionsMenu"
:boundaries-element="getBoundariesElement"
:container="getBoundariesElement"
- :disabled="isLoading || loading !== ''"
:force-name="true"
type="tertiary"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
@@ -45,6 +44,7 @@
<!-- Default actions list-->
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
+ :ref="`action-${action.id}`"
:class="{
[`files-list__row-action-${action.id}`]: true,
[`files-list__row-action--menu`]: isMenu(action.id)
@@ -64,7 +64,7 @@
<!-- Submenu actions list-->
<template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]">
<!-- Back to top-level button -->
- <NcActionButton class="files-list__row-action-back" @click="openedSubmenu = null">
+ <NcActionButton class="files-list__row-action-back" @click="onBackToMenuClick(openedSubmenu)">
<template #icon>
<ArrowLeftIcon />
</template>
@@ -271,6 +271,11 @@ export default Vue.extend({
},
async onActionClick(action, isSubmenu = false) {
+ // Skip click on loading
+ if (this.isLoading || this.loading !== '') {
+ return
+ }
+
// If the action is a submenu, we open it
if (this.enabledSubmenuActions[action.id]) {
this.openedSubmenu = action
@@ -322,6 +327,21 @@ export default Vue.extend({
return this.enabledSubmenuActions[id]?.length > 0
},
+ async onBackToMenuClick(action: FileAction) {
+ this.openedSubmenu = null
+ // Wait for first render
+ await this.$nextTick()
+
+ // Focus the previous menu action button
+ this.$nextTick(() => {
+ // Focus the action button
+ const menuAction = this.$refs[`action-${action.id}`][0]
+ if (menuAction) {
+ menuAction.$el.querySelector('button')?.focus()
+ }
+ })
+ },
+
t,
},
})
@@ -330,7 +350,7 @@ export default Vue.extend({
<style lang="scss">
// Allow right click to define the position of the menu
// only if defined
-.app-content[style*="mouse-pos-x"] .v-popper__popper {
+.content[style*="mouse-pos-x"] .v-popper__popper {
transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important;
// If the menu is too close to the bottom, we move it up
diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
index bb851ed1e0e..747ff8d6cc9 100644
--- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
+++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue
@@ -33,7 +33,7 @@
<script lang="ts">
import { Node, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import Vue, { PropType } from 'vue'
+import { type PropType, defineComponent } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
@@ -42,7 +42,7 @@ import { useKeyboardStore } from '../../store/keyboard.ts'
import { useSelectionStore } from '../../store/selection.ts'
import logger from '../../logger.js'
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryCheckbox',
components: {
@@ -52,7 +52,7 @@ export default Vue.extend({
props: {
fileid: {
- type: String,
+ type: Number,
required: true,
},
isLoading: {
@@ -86,7 +86,7 @@ export default Vue.extend({
return this.selectedFiles.includes(this.fileid)
},
index() {
- return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid))
+ return this.nodes.findIndex((node: Node) => node.fileid === this.fileid)
},
isFile() {
return this.source.type === FileType.File
@@ -112,8 +112,9 @@ export default Vue.extend({
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
- .map(file => file.fileid?.toString?.())
+ .map(file => file.fileid)
.slice(start, end + 1)
+ .filter(Boolean) as number[]
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
index 99fd45813ed..9d332491bea 100644
--- a/apps/files/src/components/FileEntryGrid.vue
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -73,36 +73,20 @@
</template>
<script lang="ts">
-import type { PropType } from 'vue'
+import { defineComponent } from 'vue'
-import { extname, join } from 'path'
-import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
-import { getUploader } from '@nextcloud/upload'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
-import { vOnClickOutside } from '@vueuse/components'
-import Vue from 'vue'
-
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { getDragAndDropPreview } from '../utils/dragUtils.ts'
-import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
-import { hashCode } from '../utils/hashUtils.ts'
-import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
+import FileEntryMixin from './FileEntryMixin.ts'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
-import logger from '../logger.js'
-
-Vue.directive('onClickOutside', vOnClickOutside)
-export default Vue.extend({
+export default defineComponent({
name: 'FileEntryGrid',
components: {
@@ -112,21 +96,11 @@ export default Vue.extend({
FileEntryPreview,
},
+ mixins: [
+ FileEntryMixin,
+ ],
+
inheritAttrs: false,
- props: {
- source: {
- type: [Folder, NcFile, Node] as PropType<Node>,
- required: true,
- },
- nodes: {
- type: Array as PropType<Node[]>,
- required: true,
- },
- filesListWidth: {
- type: Number,
- default: 0,
- },
- },
setup() {
const actionsMenuStore = useActionsMenuStore()
@@ -145,271 +119,8 @@ export default Vue.extend({
data() {
return {
- loading: '',
- dragover: false,
+ gridMode: true,
}
},
-
- computed: {
- currentView(): View {
- return this.$navigation.active as View
- },
-
- currentDir() {
- // Remove any trailing slash but leave root slash
- return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
- },
- currentFileId() {
- return this.$route.params?.fileid || this.$route.query?.fileid || null
- },
- fileid() {
- return this.source?.fileid?.toString?.()
- },
- uniqueId() {
- return hashCode(this.source.source)
- },
- isLoading() {
- return this.source.status === NodeStatus.LOADING
- },
-
- extension() {
- if (this.source.attributes?.displayName) {
- return extname(this.source.attributes.displayName)
- }
- return this.source.extension || ''
- },
- displayName() {
- const ext = this.extension
- const name = (this.source.attributes.displayName
- || this.source.basename)
-
- // Strip extension from name if defined
- return !ext ? name : name.slice(0, 0 - ext.length)
- },
-
- draggingFiles() {
- return this.draggingStore.dragging
- },
- selectedFiles() {
- return this.selectionStore.selected
- },
- isSelected() {
- return this.selectedFiles.includes(this.fileid)
- },
-
- isRenaming() {
- return this.renamingStore.renamingNode === this.source
- },
-
- isActive() {
- return this.fileid === this.currentFileId?.toString?.()
- },
-
- canDrag() {
- const canDrag = (node: Node): boolean => {
- return (node?.permissions & Permission.UPDATE) !== 0
- }
-
- // If we're dragging a selection, we need to check all files
- if (this.selectedFiles.length > 0) {
- const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- return nodes.every(canDrag)
- }
- return canDrag(this.source)
- },
-
- canDrop() {
- if (this.source.type !== FileType.Folder) {
- return false
- }
-
- // If the current folder is also being dragged, we can't drop it on itself
- if (this.draggingFiles.includes(this.fileid)) {
- return false
- }
-
- return (this.source.permissions & Permission.CREATE) !== 0
- },
-
- openedMenu: {
- get() {
- return this.actionsMenuStore.opened === this.uniqueId
- },
- set(opened) {
- this.actionsMenuStore.opened = opened ? this.uniqueId : null
- },
- },
- },
-
- watch: {
- /**
- * When the source changes, reset the preview
- * and fetch the new one.
- */
- source() {
- this.resetState()
- },
- },
-
- beforeDestroy() {
- this.resetState()
- },
-
- methods: {
- resetState() {
- // Reset loading state
- this.loading = ''
-
- this.$refs.preview.reset()
-
- // Close menu
- this.openedMenu = false
- },
-
- // Open the actions menu on right click
- onRightClick(event) {
- // If already opened, fallback to default browser
- if (this.openedMenu) {
- return
- }
-
- // If the clicked row is in the selection, open global menu
- const isMoreThanOneSelected = this.selectedFiles.length > 1
- this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
-
- // Prevent any browser defaults
- event.preventDefault()
- event.stopPropagation()
- },
-
- execDefaultAction(event) {
- if (event.ctrlKey || event.metaKey) {
- event.preventDefault()
- window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
- return false
- }
-
- this.$refs.actions.execDefaultAction(event)
- },
-
- openDetailsIfAvailable(event) {
- event.preventDefault()
- event.stopPropagation()
- if (sidebarAction?.enabled?.([this.source], this.currentView)) {
- sidebarAction.exec(this.source, this.currentView, this.currentDir)
- }
- },
-
- onDragOver(event: DragEvent) {
- this.dragover = this.canDrop
- if (!this.canDrop) {
- event.dataTransfer.dropEffect = 'none'
- return
- }
-
- // Handle copy/move drag and drop
- if (event.ctrlKey) {
- event.dataTransfer.dropEffect = 'copy'
- } else {
- event.dataTransfer.dropEffect = 'move'
- }
- },
- onDragLeave(event: DragEvent) {
- // Counter bubbling, make sure we're ending the drag
- // only when we're leaving the current element
- const currentTarget = event.currentTarget as HTMLElement
- if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
- return
- }
-
- this.dragover = false
- },
-
- async onDragStart(event: DragEvent) {
- event.stopPropagation()
- if (!this.canDrag) {
- event.preventDefault()
- event.stopPropagation()
- return
- }
-
- logger.debug('Drag started')
-
- // Reset any renaming
- this.renamingStore.$reset()
-
- // Dragging set of files, if we're dragging a file
- // that is already selected, we use the entire selection
- if (this.selectedFiles.includes(this.fileid)) {
- this.draggingStore.set(this.selectedFiles)
- } else {
- this.draggingStore.set([this.fileid])
- }
-
- const nodes = this.draggingStore.dragging
- .map(fileid => this.filesStore.getNode(fileid)) as Node[]
-
- const image = await getDragAndDropPreview(nodes)
- event.dataTransfer?.setDragImage(image, -10, -10)
- },
- onDragEnd() {
- this.draggingStore.reset()
- this.dragover = false
- logger.debug('Drag ended')
- },
-
- async onDrop(event) {
- event.preventDefault()
- event.stopPropagation()
-
- // If another button is pressed, cancel it
- // This allows cancelling the drag with the right click
- if (!this.canDrop || event.button !== 0) {
- return
- }
-
- const isCopy = event.ctrlKey
- this.dragover = false
-
- logger.debug('Dropped', { event, selection: this.draggingFiles })
-
- // Check whether we're uploading files
- if (event.dataTransfer?.files?.length > 0) {
- const uploader = getUploader()
- event.dataTransfer.files.forEach((file: File) => {
- uploader.upload(join(this.source.path, file.name), file)
- })
- logger.debug(`Uploading files to ${this.source.path}`)
- return
- }
-
- const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
- nodes.forEach(async (node: Node) => {
- Vue.set(node, 'status', NodeStatus.LOADING)
- try {
- // TODO: resolve potential conflicts prior and force overwrite
- await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
- } catch (error) {
- logger.error('Error while moving file', { error })
- if (isCopy) {
- showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
- } else {
- showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
- }
- } finally {
- Vue.set(node, 'status', undefined)
- }
- })
-
- // Reset selection after we dropped the files
- // if the dropped files are within the selection
- if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
- logger.debug('Dropped selection, resetting select store...')
- this.selectionStore.reset()
- }
- },
-
- t,
- },
})
</script>
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
new file mode 100644
index 00000000000..69638d33212
--- /dev/null
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -0,0 +1,388 @@
+/**
+ * @copyright Copyright (c) 2024 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 { PropType } from 'vue'
+
+import { extname, join } from 'path'
+import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { Upload, getUploader } from '@nextcloud/upload'
+import { vOnClickOutside } from '@vueuse/components'
+import Vue, { defineComponent } from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { getDragAndDropPreview } from '../utils/dragUtils.ts'
+import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
+import { hashCode } from '../utils/hashUtils.ts'
+import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+import logger from '../logger.js'
+
+Vue.directive('onClickOutside', vOnClickOutside)
+
+export default defineComponent({
+ props: {
+ source: {
+ type: [Folder, NcFile, Node] as PropType<Node>,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ data() {
+ return {
+ loading: '',
+ dragover: false,
+ gridMode: false,
+ }
+ },
+
+ computed: {
+ currentView(): View {
+ return this.$navigation.active as View
+ },
+
+ currentDir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
+ },
+ currentFileId() {
+ return this.$route.params?.fileid || this.$route.query?.fileid || null
+ },
+
+ fileid() {
+ return this.source?.fileid
+ },
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ extension() {
+ if (this.source.attributes?.displayName) {
+ return extname(this.source.attributes.displayName)
+ }
+ return this.source.extension || ''
+ },
+ displayName() {
+ const ext = this.extension
+ const name = (this.source.attributes.displayName
+ || this.source.basename)
+
+ // Strip extension from name if defined
+ return !ext ? name : name.slice(0, 0 - ext.length)
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging
+ },
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.fileid && this.selectedFiles.includes(this.fileid)
+ },
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+ isRenamingSmallScreen() {
+ return this.isRenaming && this.filesListWidth < 512
+ },
+
+ isActive() {
+ return this.fileid?.toString?.() === this.currentFileId?.toString?.()
+ },
+
+ canDrag() {
+ if (this.isRenaming) {
+ return false
+ }
+
+ const canDrag = (node: Node): boolean => {
+ return (node?.permissions & Permission.UPDATE) !== 0
+ }
+
+ // If we're dragging a selection, we need to check all files
+ if (this.selectedFiles.length > 0) {
+ const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ return nodes.every(canDrag)
+ }
+ return canDrag(this.source)
+ },
+
+ canDrop() {
+ if (this.source.type !== FileType.Folder) {
+ return false
+ }
+
+ // If the current folder is also being dragged, we can't drop it on itself
+ if (this.fileid && this.draggingFiles.includes(this.fileid)) {
+ return false
+ }
+
+ return (this.source.permissions & Permission.CREATE) !== 0
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId.toString()
+ },
+ set(opened) {
+ // Only reset when opening a new menu
+ if (opened) {
+ // Reset any right click position override on close
+ // Wait for css animation to be done
+ const root = this.$root.$el as HTMLElement
+ root.style.removeProperty('--mouse-pos-x')
+ root.style.removeProperty('--mouse-pos-y')
+ }
+
+ this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null
+ },
+ },
+ },
+
+ watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ */
+ source() {
+ this.resetState()
+ },
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ resetState() {
+ // Reset loading state
+ this.loading = ''
+
+ this.$refs.preview.reset()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // The grid mode is compact enough to not care about
+ // the actions menu mouse position
+ if (!this.gridMode) {
+ const root = this.$root.$el as HTMLElement
+ const contentRect = root.getBoundingClientRect()
+ // Using Math.min/max to prevent the menu from going out of the AppContent
+ // 200 = max width of the menu
+ root.style.setProperty('--mouse-pos-x', Math.max(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px')
+ root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px')
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId.toString()
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ execDefaultAction(event) {
+ if (event.ctrlKey || event.metaKey) {
+ event.preventDefault()
+ window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }))
+ return false
+ }
+
+ this.$refs.actions.execDefaultAction(event)
+ },
+
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ onDragOver(event: DragEvent) {
+ this.dragover = this.canDrop
+ if (!this.canDrop) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ return
+ }
+
+ this.dragover = false
+ },
+
+ async onDragStart(event: DragEvent) {
+ event.stopPropagation()
+ if (!this.canDrag || !this.fileid) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
+ logger.debug('Drag started', { event })
+
+ // Make sure that we're not dragging a file like the preview
+ event.dataTransfer?.clearData?.()
+
+ // Reset any renaming
+ this.renamingStore.$reset()
+
+ // Dragging set of files, if we're dragging a file
+ // that is already selected, we use the entire selection
+ if (this.selectedFiles.includes(this.fileid)) {
+ this.draggingStore.set(this.selectedFiles)
+ } else {
+ this.draggingStore.set([this.fileid])
+ }
+
+ const nodes = this.draggingStore.dragging
+ .map(fileid => this.filesStore.getNode(fileid)) as Node[]
+
+ const image = await getDragAndDropPreview(nodes)
+ event.dataTransfer?.setDragImage(image, -10, -10)
+ },
+ onDragEnd() {
+ this.draggingStore.reset()
+ this.dragover = false
+ logger.debug('Drag ended')
+ },
+
+ async onDrop(event: DragEvent) {
+ // skip if native drop like text drag and drop from files names
+ if (!this.draggingFiles && !event.dataTransfer?.files?.length) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ // If another button is pressed, cancel it
+ // This allows cancelling the drag with the right click
+ if (!this.canDrop || event.button !== 0) {
+ return
+ }
+
+ const isCopy = event.ctrlKey
+ this.dragover = false
+
+ logger.debug('Dropped', { event, selection: this.draggingFiles })
+
+ // Check whether we're uploading files
+ if (event.dataTransfer?.files
+ && event.dataTransfer.files.length > 0) {
+ const uploader = getUploader()
+
+ // Check whether the uploader is in the same folder
+ // This should never happen™
+ if (!uploader.destination.path.startsWith(uploader.destination.path)) {
+ logger.error('The current uploader destination is not the same as the current folder')
+ showError(t('files', 'An error occurred while uploading. Please try again later.'))
+ return
+ }
+
+ logger.debug(`Uploading files to ${this.source.path}`)
+ const queue = [] as Promise<Upload>[]
+ for (const file of event.dataTransfer.files) {
+ // Because the uploader destination is properly set to the current folder
+ // we can just use the basename as the relative path.
+ queue.push(uploader.upload(join(this.source.basename, file.name), file))
+ }
+
+ const results = await Promise.allSettled(queue)
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while uploading files', { errors })
+ showError(t('files', 'Some files could not be uploaded'))
+ return
+ }
+
+ logger.debug('Files uploaded successfully')
+ showSuccess(t('files', 'Files uploaded successfully'))
+ return
+ }
+
+ const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ nodes.forEach(async (node: Node) => {
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ try {
+ // TODO: resolve potential conflicts prior and force overwrite
+ await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
+ } catch (error) {
+ logger.error('Error while moving file', { error })
+ if (isCopy) {
+ showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
+ } else {
+ showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
+ }
+ } finally {
+ Vue.set(node, 'status', undefined)
+ }
+ })
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ t,
+ },
+})
diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue
index 148ce3bc4e5..c45090ca37d 100644
--- a/apps/files/src/components/FilesListTableHeader.vue
+++ b/apps/files/src/components/FilesListTableHeader.vue
@@ -73,22 +73,21 @@
<script lang="ts">
import { translate as t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import Vue from 'vue'
+import { defineComponent, type PropType } from 'vue'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
-import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
+import type { Node } from '@nextcloud/files'
-export default Vue.extend({
+export default defineComponent({
name: 'FilesListTableHeader',
components: {
FilesListTableHeaderButton,
NcCheckboxRadioSwitch,
- FilesListTableHeaderActions,
},
mixins: [
@@ -105,7 +104,7 @@ export default Vue.extend({
default: false,
},
nodes: {
- type: Array,
+ type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
@@ -181,13 +180,13 @@ export default Vue.extend({
'files-list__column': true,
'files-list__column--sortable': !!column.sort,
'files-list__row-column-custom': true,
- [`files-list__row-${this.currentView.id}-${column.id}`]: true,
+ [`files-list__row-${this.currentView?.id}-${column.id}`]: true,
}
},
onToggleAll(selected) {
if (selected) {
- const selection = this.nodes.map(node => node.fileid.toString())
+ const selection = this.nodes.map(node => node.fileid).filter(Boolean) as number[]
logger.debug('Added all nodes to selection', { selection })
this.selectionStore.setLastIndex(null)
this.selectionStore.set(selection)
diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue
index 296be604820..ff9c0ee9bc5 100644
--- a/apps/files/src/components/FilesListTableHeaderActions.vue
+++ b/apps/files/src/components/FilesListTableHeaderActions.vue
@@ -42,25 +42,26 @@
</template>
<script lang="ts">
-import { NodeStatus, getFileActions } from '@nextcloud/files'
+import { Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import Vue from 'vue'
+import Vue, { defineComponent, type PropType } from 'vue'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useFilesStore } from '../store/files.ts'
import { useSelectionStore } from '../store/selection.ts'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger.js'
+import type { FileId } from '../types'
// The registered actions list
const actions = getFileActions()
-export default Vue.extend({
+export default defineComponent({
name: 'FilesListTableHeaderActions',
components: {
@@ -76,11 +77,11 @@ export default Vue.extend({
props: {
currentView: {
- type: Object,
+ type: Object as PropType<View>,
required: true,
},
selectedNodes: {
- type: Array,
+ type: Array as PropType<FileId[]>,
default: () => ([]),
},
},
@@ -117,7 +118,7 @@ export default Vue.extend({
nodes() {
return this.selectedNodes
.map(fileid => this.getNode(fileid))
- .filter(node => node)
+ .filter(Boolean) as Node[]
},
areSomeNodesLoading() {
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index ed0096e9792..b6a11391dc1 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -73,7 +73,7 @@ import type { Node as NcNode } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { UserConfig } from '../types'
-import { getFileListHeaders, Folder, View, getFileActions } from '@nextcloud/files'
+import { getFileListHeaders, Folder, View, getFileActions, FileType } from '@nextcloud/files'
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
@@ -139,6 +139,7 @@ export default defineComponent({
FileEntryGrid,
headers: getFileListHeaders(),
scrollToIndex: 0,
+ openFileId: null as number|null,
}
},
@@ -151,6 +152,14 @@ export default defineComponent({
return parseInt(this.$route.params.fileid) || null
},
+ /**
+ * If the current `fileId` should be opened
+ * The state of the `openfile` query param
+ */
+ openFile() {
+ return !!this.$route.query.openfile
+ },
+
summary() {
return getSummaryFor(this.nodes)
},
@@ -199,6 +208,12 @@ export default defineComponent({
fileId(fileId) {
this.scrollToFile(fileId, false)
},
+
+ openFile(open: boolean) {
+ if (open) {
+ this.$nextTick(() => this.handleOpenFile(this.fileId))
+ }
+ },
},
mounted() {
@@ -206,9 +221,11 @@ export default defineComponent({
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
- this.scrollToFile(this.fileId)
- this.openSidebarForFile(this.fileId)
- this.handleOpenFile()
+ // handle initially opening a given file
+ const { id } = loadState<{ id?: number }>('files', 'openFileInfo', {})
+ this.scrollToFile(id ?? this.fileId)
+ this.openSidebarForFile(id ?? this.fileId)
+ this.handleOpenFile(id ?? null)
},
beforeDestroy() {
@@ -241,18 +258,22 @@ export default defineComponent({
}
},
- handleOpenFile() {
- const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number })
- if (openFileInfo === undefined) {
+ /**
+ * Handle opening a file (e.g. by ?openfile=true)
+ * @param fileId File to open
+ */
+ handleOpenFile(fileId: number|null) {
+ if (fileId === null || this.openFileId === fileId) {
return
}
- const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode
- if (node === undefined) {
+ const node = this.nodes.find(n => n.fileid === fileId) as NcNode
+ if (node === undefined || node.type === FileType.Folder) {
return
}
logger.debug('Opening file ' + node.path, { node })
+ this.openFileId = fileId
getFileActions()
.filter(action => !action.enabled || action.enabled([node], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
@@ -339,14 +360,21 @@ export default defineComponent({
.files-list__table {
display: block;
+
+ &.files-list__table--with-thead-overlay {
+ // Hide the table header below the overlay
+ margin-top: calc(-1 * var(--row-height));
+ }
}
.files-list__thead-overlay {
- position: absolute;
+ // Pinned on top when scrolling
+ position: sticky;
top: 0;
- left: var(--row-height); // Save space for a row checkbox
- right: 0;
- z-index: 1000;
+ // Save space for a row checkbox
+ margin-left: var(--row-height);
+ // More than .files-list__thead
+ z-index: 20;
display: flex;
align-items: center;
diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue
index 943d61cf0f5..8f96481232d 100644
--- a/apps/files/src/components/NavigationQuota.vue
+++ b/apps/files/src/components/NavigationQuota.vue
@@ -88,8 +88,17 @@ export default {
},
mounted() {
- // Warn the user if the available storage is 0 on page load
- if (this.storageStats?.free <= 0) {
+ // If the user has a quota set, warn if the available account storage is <=0
+ //
+ // NOTE: This doesn't catch situations where actual *server*
+ // disk (non-quota) space is low, but those should probably
+ // be handled differently anyway since a regular user can't
+ // can't do much about them (If we did want to indicate server disk
+ // space matters to users, we'd probably want to use a warning
+ // specific to that situation anyhow. So this covers warning covers
+ // our primary day-to-day concern (individual account quota usage).
+ //
+ if (this.storageStats?.quota > 0 && this.storageStats?.free <= 0) {
this.showStorageFullWarning()
}
},
@@ -122,8 +131,9 @@ export default {
throw new Error('Invalid storage stats')
}
- // Warn the user if the available storage changed from > 0 to 0
- if (this.storageStats?.free > 0 && response.data.data?.free <= 0) {
+ // Warn the user if the available account storage changed from > 0 to 0
+ // (unless only because quota was intentionally set to 0 by admin in the interim)
+ if (this.storageStats?.free > 0 && response.data.data?.free <= 0 && response.data.data?.quota > 0) {
this.showStorageFullWarning()
}
diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue
new file mode 100644
index 00000000000..38337ddf4b8
--- /dev/null
+++ b/apps/files/src/components/NewNodeDialog.vue
@@ -0,0 +1,149 @@
+<!--
+ - @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ -
+ - @author Ferdinand Thiessen <opensource@fthiessen.de>
+ -
+ - @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/>.
+ -
+ -->
+<template>
+ <NcDialog :name="name"
+ :open="open"
+ close-on-click-outside
+ out-transition
+ @update:open="onClose">
+ <template #actions>
+ <NcButton type="primary"
+ :disabled="!isUniqueName"
+ @click="onCreate">
+ {{ t('files', 'Create') }}
+ </NcButton>
+ </template>
+ <form @submit.prevent="onCreate">
+ <NcTextField ref="input"
+ :error="!isUniqueName"
+ :helper-text="errorMessage"
+ :label="label"
+ :value.sync="localDefaultName" />
+ </form>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import { translate as t } from '@nextcloud/l10n'
+import { getUniqueName } from '../utils/fileUtils'
+
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+interface ICanFocus {
+ focus: () => void
+}
+
+export default defineComponent({
+ name: 'NewNodeDialog',
+ components: {
+ NcButton,
+ NcDialog,
+ NcTextField,
+ },
+ props: {
+ /**
+ * The name to be used by default
+ */
+ defaultName: {
+ type: String,
+ default: t('files', 'New folder'),
+ },
+ /**
+ * Other files that are in the current directory
+ */
+ otherNames: {
+ type: Array as PropType<string[]>,
+ default: () => [],
+ },
+ /**
+ * Open state of the dialog
+ */
+ open: {
+ type: Boolean,
+ default: true,
+ },
+ /**
+ * Dialog name
+ */
+ name: {
+ type: String,
+ default: t('files', 'Create new folder'),
+ },
+ /**
+ * Input label
+ */
+ label: {
+ type: String,
+ default: t('files', 'Folder name'),
+ },
+ },
+ emits: {
+ close: (name: string|null) => name === null || name,
+ },
+ data() {
+ return {
+ localDefaultName: this.defaultName || t('files', 'New folder'),
+ }
+ },
+ computed: {
+ errorMessage() {
+ if (this.isUniqueName) {
+ return ''
+ } else {
+ return t('files', 'A file or folder with that name already exists.')
+ }
+ },
+ uniqueName() {
+ return getUniqueName(this.localDefaultName, this.otherNames)
+ },
+ isUniqueName() {
+ return this.localDefaultName === this.uniqueName
+ },
+ },
+ watch: {
+ defaultName() {
+ this.localDefaultName = this.defaultName || t('files', 'New folder')
+ },
+ },
+ mounted() {
+ // on mounted lets use the unique name
+ this.localDefaultName = this.uniqueName
+ this.$nextTick(() => (this.$refs.input as unknown as ICanFocus)?.focus?.())
+ },
+ methods: {
+ t,
+ onCreate() {
+ this.$emit('close', this.localDefaultName)
+ },
+ onClose(state: boolean) {
+ if (!state) {
+ this.$emit('close', null)
+ }
+ },
+ },
+})
+</script>
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index 77454772f55..173fe284d27 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -1,15 +1,15 @@
<template>
<div class="files-list" data-cy-files-list>
- <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay">
- <slot name="header-overlay" />
- </div>
-
<!-- Header -->
<div ref="before" class="files-list__before">
<slot name="before" />
</div>
- <table class="files-list__table">
+ <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay">
+ <slot name="header-overlay" />
+ </div>
+
+ <table class="files-list__table" :class="{ 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'] }">
<!-- Accessibility table caption for screen readers -->
<caption v-if="caption" class="hidden-visually">
{{ caption }}
@@ -243,6 +243,11 @@ export default Vue.extend({
methods: {
scrollTo(index: number) {
+ const targetRow = Math.ceil(this.dataSources.length / this.columnCount)
+ if (targetRow < this.rowCount) {
+ logger.debug('VirtualList: Skip scrolling. nothing to scroll', { index, targetRow, rowCount: this.rowCount })
+ return
+ }
this.index = index
// Scroll to one row and a half before the index
const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
diff --git a/apps/files/src/init-templates.ts b/apps/files/src/init-templates.ts
deleted file mode 100644
index 6803143d4b2..00000000000
--- a/apps/files/src/init-templates.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @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 { Entry } from '@nextcloud/files'
-import type { TemplateFile } from './types'
-
-import { Folder, Node, Permission, addNewFileMenuEntry, removeNewFileMenuEntry } from '@nextcloud/files'
-import { generateOcsUrl } from '@nextcloud/router'
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { join } from 'path'
-import { loadState } from '@nextcloud/initial-state'
-import { showError } from '@nextcloud/dialogs'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import axios from '@nextcloud/axios'
-import Vue from 'vue'
-
-import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
-
-import TemplatePickerView from './views/TemplatePicker.vue'
-import { getUniqueName } from './utils/fileUtils.ts'
-import { getCurrentUser } from '@nextcloud/auth'
-
-// Set up logger
-const logger = getLoggerBuilder()
- .setApp('files')
- .detectUser()
- .build()
-
-// Add translates functions
-Vue.mixin({
- methods: {
- t,
- n,
- },
-})
-
-// Create document root
-const TemplatePickerRoot = document.createElement('div')
-TemplatePickerRoot.id = 'template-picker'
-document.body.appendChild(TemplatePickerRoot)
-
-// Retrieve and init templates
-let templates = loadState<TemplateFile[]>('files', 'templates', [])
-let templatesPath = loadState('files', 'templates_path', false)
-logger.debug('Templates providers', { templates })
-logger.debug('Templates folder', { templatesPath })
-
-// Init vue app
-const View = Vue.extend(TemplatePickerView)
-const TemplatePicker = new View({
- name: 'TemplatePicker',
- propsData: {
- logger,
- },
-})
-TemplatePicker.$mount('#template-picker')
-if (!templatesPath) {
- logger.debug('Templates folder not initialized')
- addNewFileMenuEntry({
- id: 'template-picker',
- displayName: t('files', 'Create new templates folder'),
- iconSvgInline: PlusSvg,
- order: 10,
- enabled(context: Folder): boolean {
- // Allow creation on your own folders only
- if (context.owner !== getCurrentUser()?.uid) {
- return false
- }
- return (context.permissions & Permission.CREATE) !== 0
- },
- handler(context: Folder, content: Node[]) {
- // Check for conflicts
- const contentNames = content.map((node: Node) => node.basename)
- const name = getUniqueName(t('files', 'Templates'), contentNames)
-
- // Create the template folder
- initTemplatesFolder(context, name)
-
- // Remove the menu entry
- removeNewFileMenuEntry('template-picker')
- },
- } as Entry)
-}
-
-// Init template files menu
-templates.forEach((provider, index) => {
- addNewFileMenuEntry({
- id: `template-new-${provider.app}-${index}`,
- displayName: provider.label,
- // TODO: migrate to inline svg
- iconClass: provider.iconClass || 'icon-file',
- enabled(context: Folder): boolean {
- return (context.permissions & Permission.CREATE) !== 0
- },
- order: 11,
- handler(context: Folder, content: Node[]) {
- // Check for conflicts
- const contentNames = content.map((node: Node) => node.basename)
- const name = getUniqueName(provider.label + provider.extension, contentNames)
-
- // Create the file
- TemplatePicker.open(name, provider)
- },
- } as Entry)
-})
-
-// Init template folder
-const initTemplatesFolder = async function(directory: Folder, name: string) {
- const templatePath = join(directory.path, name)
- try {
- logger.debug('Initializing the templates directory', { templatePath })
- const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
- templatePath,
- copySystemTemplates: true,
- })
-
- // Go to template directory
- window.OCP.Files.Router.goToRoute(
- null, // use default route
- { view: 'files', fileid: undefined },
- { dir: templatePath },
- )
-
- templates = response.data.ocs.data.templates
- templatesPath = response.data.ocs.data.template_path
- } catch (error) {
- logger.error('Unable to initialize the templates directory')
- showError(t('files', 'Unable to initialize the templates directory'))
- }
-}
diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts
index 8002f33ff56..c3b4b570e12 100644
--- a/apps/files/src/init.ts
+++ b/apps/files/src/init.ts
@@ -31,14 +31,15 @@ import { action as openInFilesAction } from './actions/openInFilesAction'
import { action as renameAction } from './actions/renameAction'
import { action as sidebarAction } from './actions/sidebarAction'
import { action as viewInFolderAction } from './actions/viewInFolderAction'
-import { entry as newFolderEntry } from './newMenu/newFolder'
+import { entry as newFolderEntry } from './newMenu/newFolder.ts'
+import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
+import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
import registerFavoritesView from './views/favorites'
import registerRecentView from './views/recent'
import registerFilesView from './views/files'
import registerPreviewServiceWorker from './services/ServiceWorker.js'
-import './init-templates'
import { initLivePhotos } from './services/LivePhotos'
@@ -56,6 +57,8 @@ registerFileAction(viewInFolderAction)
// Register new menu entry
addNewFileMenuEntry(newFolderEntry)
+addNewFileMenuEntry(newTemplatesFolder)
+registerTemplateEntries()
// Register files views
registerFavoritesView()
@@ -66,5 +69,6 @@ registerRecentView()
registerPreviewServiceWorker()
registerDavProperty('nc:hidden', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:is-mount-root', { nc: 'http://nextcloud.org/ns' })
initLivePhotos()
diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts
index 0c0c5b165e6..7b25c88a697 100644
--- a/apps/files/src/main.ts
+++ b/apps/files/src/main.ts
@@ -3,12 +3,11 @@ 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'
import router from './router/router'
import RouterService from './services/RouterService'
import SettingsModel from './models/Setting.js'
import SettingsService from './services/Settings.js'
+import FilesApp from './FilesApp.vue'
// @ts-expect-error __webpack_nonce__ is injected by webpack
__webpack_nonce__ = btoa(getRequestToken())
@@ -43,23 +42,8 @@ const Settings = new SettingsService()
Object.assign(window.OCA.Files, { Settings })
Object.assign(window.OCA.Files.Settings, { Setting: SettingsModel })
-// Init Navigation View
-const View = Vue.extend(NavigationView)
-const FilesNavigationRoot = new View({
- name: 'FilesNavigationRoot',
- propsData: {
- Navigation,
- },
+const FilesAppVue = Vue.extend(FilesApp)
+new FilesAppVue({
router,
pinia,
-})
-FilesNavigationRoot.$mount('#app-navigation-files')
-
-// Init content list view
-const ListView = Vue.extend(FilesListView)
-const FilesList = new ListView({
- name: 'FilesListRoot',
- router,
- pinia,
-})
-FilesList.$mount('#app-content-vue')
+}).$mount('#content')
diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts
index 37dcf6d3d89..64ab8004e78 100644
--- a/apps/files/src/newMenu/newFolder.ts
+++ b/apps/files/src/newMenu/newFolder.ts
@@ -31,7 +31,7 @@ import axios from '@nextcloud/axios'
import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw'
-import { getUniqueName } from '../utils/fileUtils.ts'
+import { newNodeName } from '../utils/newNodeDialog'
import logger from '../logger'
type createFolderResponse = {
@@ -63,23 +63,27 @@ export const entry = {
iconSvgInline: FolderPlusSvg,
order: 0,
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, name)
+ const name = await newNodeName(t('files', 'New folder'), content)
+ if (name !== null) {
+ const { fileid, source } = await createNewFolder(context, 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,
+ })
- // 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,
- })
-
- showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
- logger.debug('Created new folder', { folder, source })
- emit('files:node:created', folder)
- emit('files:node:rename', folder)
+ showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
+ logger.debug('Created new folder', { folder, source })
+ emit('files:node:created', folder)
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: folder.fileid },
+ { dir: context.path },
+ )
+ }
},
} as Entry
diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts
new file mode 100644
index 00000000000..5e69181995e
--- /dev/null
+++ b/apps/files/src/newMenu/newFromTemplate.ts
@@ -0,0 +1,88 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Julius Härtl <jus@bitgrid.net>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @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 { Entry } from '@nextcloud/files'
+import type { ComponentInstance } from 'vue'
+import type { TemplateFile } from '../types.ts'
+
+import { Folder, Node, Permission, addNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { newNodeName } from '../utils/newNodeDialog'
+import { translate as t } from '@nextcloud/l10n'
+import Vue, { defineAsyncComponent } from 'vue'
+
+// async to reduce bundle size
+const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue'))
+let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null
+
+const getTemplatePicker = async () => {
+ if (TemplatePicker === null) {
+ // Create document root
+ const mountingPoint = document.createElement('div')
+ mountingPoint.id = 'template-picker'
+ document.body.appendChild(mountingPoint)
+
+ // Init vue app
+ TemplatePicker = new Vue({
+ render: (h) => h(TemplatePickerVue, { ref: 'picker' }),
+ methods: { open(...args) { this.$refs.picker.open(...args) } },
+ el: mountingPoint,
+ })
+ }
+ return TemplatePicker
+}
+
+/**
+ * Register all new-file-menu entries for all template providers
+ */
+export function registerTemplateEntries() {
+ const templates = loadState<TemplateFile[]>('files', 'templates', [])
+
+ // Init template files menu
+ templates.forEach((provider, index) => {
+ addNewFileMenuEntry({
+ id: `template-new-${provider.app}-${index}`,
+ displayName: provider.label,
+ // TODO: migrate to inline svg
+ iconClass: provider.iconClass || 'icon-file',
+ enabled(context: Folder): boolean {
+ return (context.permissions & Permission.CREATE) !== 0
+ },
+ order: 11,
+ async handler(context: Folder, content: Node[]) {
+ const templatePicker = getTemplatePicker()
+ const name = await newNodeName(`${provider.label}${provider.extension}`, content, {
+ label: t('files', 'Filename'),
+ name: provider.label,
+ })
+
+ if (name !== null) {
+ // Create the file
+ const picker = await templatePicker
+ picker.open(name, provider)
+ }
+ },
+ } as Entry)
+ })
+}
diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts
new file mode 100644
index 00000000000..fafee553a10
--- /dev/null
+++ b/apps/files/src/newMenu/newTemplatesFolder.ts
@@ -0,0 +1,100 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Julius Härtl <jus@bitgrid.net>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @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 { Entry, Folder, Node } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError } from '@nextcloud/dialogs'
+import { Permission, removeNewFileMenuEntry } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { join } from 'path'
+import { newNodeName } from '../utils/newNodeDialog'
+
+import PlusSvg from '@mdi/svg/svg/plus.svg?raw'
+import axios from '@nextcloud/axios'
+import logger from '../logger.js'
+
+let templatesPath = loadState<string|false>('files', 'templates_path', false)
+logger.debug('Initial templates folder', { templatesPath })
+
+/**
+ * Init template folder
+ * @param directory Folder where to create the templates folder
+ * @param name Name to use or the templates folder
+ */
+const initTemplatesFolder = async function(directory: Folder, name: string) {
+ const templatePath = join(directory.path, name)
+ try {
+ logger.debug('Initializing the templates directory', { templatePath })
+ const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), {
+ templatePath,
+ copySystemTemplates: true,
+ })
+
+ // Go to template directory
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ { view: 'files', fileid: undefined },
+ { dir: templatePath },
+ )
+
+ logger.info('Created new templates folder', {
+ ...data.ocs.data,
+ })
+ templatesPath = data.ocs.data.templates_path as string
+ } catch (error) {
+ logger.error('Unable to initialize the templates directory')
+ showError(t('files', 'Unable to initialize the templates directory'))
+ }
+}
+
+export const entry = {
+ id: 'template-picker',
+ displayName: t('files', 'Create new templates folder'),
+ iconSvgInline: PlusSvg,
+ order: 10,
+ enabled(context: Folder): boolean {
+ // Templates folder already initialized
+ if (templatesPath) {
+ return false
+ }
+ // Allow creation on your own folders only
+ if (context.owner !== getCurrentUser()?.uid) {
+ return false
+ }
+ return (context.permissions & Permission.CREATE) !== 0
+ },
+ async handler(context: Folder, content: Node[]) {
+ const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder') })
+
+ if (name !== null) {
+ // Create the template folder
+ initTemplatesFolder(context, name)
+
+ // Remove the menu entry
+ removeNewFileMenuEntry('template-picker')
+ }
+ },
+} as Entry
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
index 372b849bcc4..d1e8dd9ed5a 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -32,7 +32,7 @@ import { translate as t } from '@nextcloud/l10n'
import logger from '../logger.js'
-export const handleDrop = async (data: DataTransfer) => {
+export const handleDrop = async (data: DataTransfer): Promise<Upload[]> => {
// TODO: Maybe handle `getAsFileSystemHandle()` in the future
const uploads = [] as Upload[]
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
index a293154f625..bcfb368882d 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -40,9 +40,14 @@ interface ResponseProps extends DAVResultResponseProps {
}
export const resultToNode = function(node: FileStat): File | Folder {
+ const userId = getCurrentUser()?.uid
+ if (!userId) {
+ throw new Error('No user id found')
+ }
+
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
- const owner = (props['owner-id'] || getCurrentUser()?.uid) as string
+ const owner = (props['owner-id'] || userId).toString()
const source = generateRemoteUrl('dav' + rootPath + node.filename)
const id = props?.fileid < 0
@@ -53,7 +58,7 @@ export const resultToNode = function(node: FileStat): File | Folder {
id,
source,
mtime: new Date(node.lastmod),
- mime: node.mime as string,
+ mime: node.mime || 'application/octet-stream',
size: props?.size as number || 0,
permissions,
owner,
diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts
index cbd3f71600a..c5f34c2dbe0 100644
--- a/apps/files/src/store/userconfig.ts
+++ b/apps/files/src/store/userconfig.ts
@@ -27,12 +27,13 @@ import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import Vue from 'vue'
-const userConfig = loadState('files', 'config', {
+const userConfig = loadState<UserConfig>('files', 'config', {
show_hidden: false,
crop_image_previews: true,
sort_favorites_first: true,
+ sort_folders_first: true,
grid_view: false,
-}) as UserConfig
+})
export const useUserConfigStore = function(...args) {
const store = defineStore('userconfig', {
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index d2bfcaed0ee..0e9dd6fb531 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -119,4 +119,5 @@ export interface TemplateFile {
iconClass?: string
mimetypes: string[]
ratio?: number
+ templates?: Record<string, unknown>[]
}
diff --git a/apps/files/src/utils/newNodeDialog.ts b/apps/files/src/utils/newNodeDialog.ts
new file mode 100644
index 00000000000..f53694fc68c
--- /dev/null
+++ b/apps/files/src/utils/newNodeDialog.ts
@@ -0,0 +1,57 @@
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @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'
+import { spawnDialog } from '@nextcloud/dialogs'
+import NewNodeDialog from '../components/NewNodeDialog.vue'
+
+interface ILabels {
+ /**
+ * Dialog heading, defaults to "New folder name"
+ */
+ name?: string
+ /**
+ * Label for input box, defaults to "New folder"
+ */
+ label?: string
+}
+
+/**
+ * Ask user for file or folder name
+ * @param defaultName Default name to use
+ * @param folderContent Nodes with in the current folder to check for unique name
+ * @param labels Labels to set on the dialog
+ * @return string if successfull otherwise null if aborted
+ */
+export function newNodeName(defaultName: string, folderContent: Node[], labels: ILabels = {}) {
+ const contentNames = folderContent.map((node: Node) => node.basename)
+
+ return new Promise<string|null>((resolve) => {
+ spawnDialog(NewNodeDialog, {
+ ...labels,
+ defaultName,
+ otherNames: contentNames,
+ }, (folderName) => {
+ resolve(folderName as string|null)
+ })
+ })
+}
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index fabfccd6ca1..4e80379f632 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -35,7 +35,7 @@
@click="openSharingSidebar">
<template #icon>
<LinkIcon v-if="shareButtonType === Type.SHARE_TYPE_LINK" />
- <ShareVariantIcon v-else :size="20" />
+ <AccountPlusIcon v-else :size="20" />
</template>
</NcButton>
@@ -143,7 +143,7 @@ import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
-import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue'
+import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
@@ -177,7 +177,7 @@ export default defineComponent({
NcIconSvgWrapper,
NcLoadingIcon,
PlusIcon,
- ShareVariantIcon,
+ AccountPlusIcon,
UploadPicker,
ViewGridIcon,
},
@@ -257,7 +257,7 @@ export default defineComponent({
// 1: Sort favorites first if enabled
...(this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : []),
// 2: Sort folders first if sorting by name
- ...(this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : []),
+ ...(this.userConfig.sort_folders_first ? [v => v.type !== 'folder'] : []),
// 3: Use sorting mode if NOT basename (to be able to use displayName too)
...(this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : []),
// 4: Use displayName if available, fallback to name
@@ -269,7 +269,7 @@ export default defineComponent({
// (for 1): always sort favorites before normal files
...(this.userConfig.sort_favorites_first ? ['asc'] : []),
// (for 2): always sort folders before files
- ...(this.sortingMode === 'basename' ? ['asc'] : []),
+ ...(this.userConfig.sort_folders_first ? ['asc'] : []),
// (for 3): Reverse if sorting by mtime as mtime higher means edited more recent -> lower
...(this.sortingMode === 'mtime' ? [this.isAscSorting ? 'desc' : 'asc'] : []),
// (also for 3 so make sure not to conflict with 2 and 3)
@@ -566,15 +566,20 @@ export default defineComponent({
/**
* Refreshes the current folder on update.
*
- * @param {Node} node is the file/folder being updated.
+ * @param node is the file/folder being updated.
*/
- onUpdatedNode(node) {
+ onUpdatedNode(node?: Node) {
if (node?.fileid === this.currentFolder?.fileid) {
this.fetchContent()
}
},
openSharingSidebar() {
+ if (!this.currentFolder) {
+ logger.debug('No current folder found for opening sharing sidebar')
+ return
+ }
+
if (window?.OCA?.Files?.Sidebar?.setActiveTab) {
window.OCA.Files.Sidebar.setActiveTab('sharing')
}
@@ -620,9 +625,9 @@ $navigationToggleSize: 50px;
}
&-share-button {
- opacity: .3;
+ color: var(--color-text-maxcontrast) !important;
&--shared {
- opacity: 1;
+ color: var(--color-main-text) !important;
}
}
}
diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts
index cf3512bce0e..07d9eee80cb 100644
--- a/apps/files/src/views/Navigation.cy.ts
+++ b/apps/files/src/views/Navigation.cy.ts
@@ -7,11 +7,15 @@ import router from '../router/router'
import { useViewConfigStore } from '../store/viewConfig'
import { Folder, View, getNavigation } from '@nextcloud/files'
+import Vue from 'vue'
+
describe('Navigation renders', () => {
delete window._nc_navigation
const Navigation = getNavigation()
before(() => {
+ Vue.prototype.$navigation = Navigation
+
cy.mockInitialState('files', 'storageStats', {
used: 1000 * 1000 * 1000,
quota: -1,
@@ -22,9 +26,6 @@ describe('Navigation renders', () => {
it('renders', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -42,6 +43,10 @@ describe('Navigation API', () => {
delete window._nc_navigation
const Navigation = getNavigation()
+ before(() => {
+ Vue.prototype.$navigation = Navigation
+ })
+
it('Check API entries rendering', () => {
Navigation.register(new View({
id: 'files',
@@ -52,9 +57,6 @@ describe('Navigation API', () => {
}))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -79,9 +81,6 @@ describe('Navigation API', () => {
}))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -107,9 +106,6 @@ describe('Navigation API', () => {
}))
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -159,13 +155,14 @@ describe('Quota rendering', () => {
delete window._nc_navigation
const Navigation = getNavigation()
+ before(() => {
+ Vue.prototype.$navigation = Navigation
+ })
+
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -183,9 +180,6 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -206,9 +200,6 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
@@ -230,9 +221,6 @@ describe('Quota rendering', () => {
})
cy.mount(NavigationView, {
- propsData: {
- Navigation,
- },
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue
index 118a960bf5c..ef82a036cee 100644
--- a/apps/files/src/views/Navigation.vue
+++ b/apps/files/src/views/Navigation.vue
@@ -75,7 +75,7 @@
</template>
<script lang="ts">
-import { emit, subscribe } from '@nextcloud/event-bus'
+import { emit } from '@nextcloud/event-bus'
import { translate } from '@nextcloud/l10n'
import Cog from 'vue-material-design-icons/Cog.vue'
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
@@ -85,7 +85,7 @@ import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js
import { setPageHeading } from '../../../../core/src/OCP/accessibility.js'
import { useViewConfigStore } from '../store/viewConfig.ts'
import logger from '../logger.js'
-import type { Navigation, View } from '@nextcloud/files'
+import type { View } from '@nextcloud/files'
import NavigationQuota from '../components/NavigationQuota.vue'
import SettingsModal from './Settings.vue'
@@ -101,14 +101,6 @@ export default {
SettingsModal,
},
- props: {
- // eslint-disable-next-line vue/prop-name-casing
- Navigation: {
- type: Object as Navigation,
- required: true,
- },
- },
-
setup() {
const viewConfigStore = useViewConfigStore()
return {
@@ -132,7 +124,7 @@ export default {
},
views(): View[] {
- return this.Navigation.views
+ return this.$navigation.views
},
parentViews(): View[] {
@@ -164,7 +156,7 @@ export default {
watch: {
currentView(view, oldView) {
if (view.id !== oldView?.id) {
- this.Navigation.setActive(view)
+ this.$navigation.setActive(view)
logger.debug('Navigation changed', { id: view.id, view })
this.showView(view)
@@ -193,7 +185,7 @@ export default {
showView(view: View) {
// Closing any opened sidebar
window?.OCA?.Files?.Sidebar?.close?.()
- this.Navigation.setActive(view)
+ this.$navigation.setActive(view)
setPageHeading(view.name)
emit('files:navigation:changed', view)
},
@@ -201,6 +193,7 @@ export default {
/**
* Expand/collapse a a view with children and permanently
* save this setting in the server.
+ * @param view
*/
onToggleExpand(view: View) {
// Invert state
@@ -213,6 +206,7 @@ export default {
/**
* Check if a view is expanded by user config
* or fallback to the default value.
+ * @param view
*/
isExpanded(view: View): boolean {
return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean'
@@ -222,6 +216,7 @@ export default {
/**
* Generate the route to a view
+ * @param view
*/
generateToNavigation(view: View) {
if (view.params) {
diff --git a/apps/files/src/views/Settings.vue b/apps/files/src/views/Settings.vue
index d3eb318d4fa..cefbdab4eec 100644
--- a/apps/files/src/views/Settings.vue
+++ b/apps/files/src/views/Settings.vue
@@ -30,6 +30,10 @@
@update:checked="setConfig('sort_favorites_first', $event)">
{{ t('files', 'Sort favorites first') }}
</NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked="userConfig.sort_folders_first"
+ @update:checked="setConfig('sort_folders_first', $event)">
+ {{ t('files', 'Sort folders before files') }}
+ </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.show_hidden"
@update:checked="setConfig('show_hidden', $event)">
{{ t('files', 'Show hidden files') }}
diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue
index d0c6b90b49d..01cea14f8eb 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -25,7 +25,6 @@
ref="sidebar"
v-bind="appSidebar"
:force-menu="true"
- tabindex="0"
@close="close"
@update:active="setActiveTab"
@[defaultActionListener].stop.prevent="onDefaultAction"
@@ -470,6 +469,10 @@ export default {
throw new Error(`Invalid path '${path}'`)
}
+ // Only focus the tab when the selected file/tab is changed in already opened sidebar
+ // Focusing the sidebar on first file open is handled by NcAppSidebar
+ const focusTabAfterLoad = !!this.Sidebar.file
+
// update current opened file
this.Sidebar.file = path
@@ -488,19 +491,23 @@ export default {
view.setFileInfo(this.fileInfo)
})
- this.$nextTick(() => {
- if (this.$refs.tabs) {
- this.$refs.tabs.updateTabs()
- }
- this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
- })
+ await this.$nextTick()
+
+ this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id)
+
+ this.loading = false
+
+ await this.$nextTick()
+
+ if (focusTabAfterLoad) {
+ this.$refs.sidebar.focusActiveTabContent()
+ }
} catch (error) {
+ this.loading = false
this.error = t('files', 'Error while loading the file data')
console.error('Error while loading the file data', error)
throw new Error(error)
- } finally {
- this.loading = false
}
},
diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue
index 5f248602d4d..93dc7ca574f 100644
--- a/apps/files/src/views/TemplatePicker.vue
+++ b/apps/files/src/views/TemplatePicker.vue
@@ -61,23 +61,27 @@
</template>
<script lang="ts">
-import { emit, subscribe } from '@nextcloud/event-bus'
+import type { TemplateFile } from '../types.ts'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { showError } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
import { File } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
import { generateRemoteUrl } from '@nextcloud/router'
-import { getCurrentUser } from '@nextcloud/auth'
import { normalize, extname, join } from 'path'
-import { showError } from '@nextcloud/dialogs'
+import { defineComponent } from 'vue'
+import { createFromTemplate, getTemplates } from '../services/Templates.js'
+
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import Vue from 'vue'
-
-import { createFromTemplate, getTemplates } from '../services/Templates.js'
import TemplatePreview from '../components/TemplatePreview.vue'
+import logger from '../logger.js'
const border = 2
const margin = 8
-export default Vue.extend({
+export default defineComponent({
name: 'TemplatePicker',
components: {
@@ -86,40 +90,34 @@ export default Vue.extend({
TemplatePreview,
},
- props: {
- logger: {
- type: Object,
- required: true,
- },
- },
-
data() {
return {
// Check empty template by default
checked: -1,
loading: false,
- name: null,
+ name: null as string|null,
opened: false,
- provider: null,
+ provider: null as TemplateFile|null,
}
},
computed: {
extension() {
- return extname(this.name)
+ return extname(this.name ?? '')
},
+
nameWithoutExt() {
// Strip extension from name if defined
return !this.extension
? this.name
- : this.name.slice(0, 0 - this.extension.length)
+ : this.name!.slice(0, 0 - this.extension.length)
},
emptyTemplate() {
return {
basename: t('files', 'Blank'),
fileid: -1,
- filename: this.t('files', 'Blank'),
+ filename: t('files', 'Blank'),
hasPreview: false,
mime: this.provider?.mimetypes[0] || this.provider?.mimetypes,
}
@@ -130,7 +128,7 @@ export default Vue.extend({
return null
}
- return this.provider.templates.find(template => template.fileid === this.checked)
+ return this.provider.templates!.find((template) => template.fileid === this.checked)
},
/**
@@ -159,6 +157,8 @@ export default Vue.extend({
},
methods: {
+ t,
+
/**
* Open the picker
*
@@ -201,9 +201,9 @@ export default Vue.extend({
/**
* Manages the radio template picker change
*
- * @param {number} fileid the selected template file id
+ * @param fileid the selected template file id
*/
- onCheck(fileid) {
+ onCheck(fileid: number) {
this.checked = fileid
},
@@ -213,22 +213,22 @@ export default Vue.extend({
// If the file doesn't have an extension, add the default one
if (this.nameWithoutExt === this.name) {
- this.logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
- this.name = this.name + this.provider?.extension
+ logger.warn('Fixed invalid filename', { name: this.name, extension: this.provider?.extension })
+ this.name = `${this.name}${this.provider?.extension ?? ''}`
}
try {
const fileInfo = await createFromTemplate(
normalize(`${currentDirectory}/${this.name}`),
- this.selectedTemplate?.filename,
- this.selectedTemplate?.templateType,
+ this.selectedTemplate?.filename as string ?? '',
+ this.selectedTemplate?.templateType as string ?? '',
)
- this.logger.debug('Created new file', fileInfo)
+ logger.debug('Created new file', fileInfo)
const owner = getCurrentUser()?.uid || null
const node = new File({
id: fileInfo.fileid,
- source: generateRemoteUrl(join('dav/files', owner, fileInfo.filename)),
+ source: generateRemoteUrl(join(`dav/files/${owner}`, fileInfo.filename)),
root: `/files/${owner}`,
mime: fileInfo.mime,
mtime: new Date(fileInfo.lastmod * 1000),
@@ -243,19 +243,13 @@ export default Vue.extend({
// Update files list
emit('files:node:created', node)
-
- // Open the new file
- window.OCP.Files.Router.goToRoute(
- null, // use default route
- { view: 'files', fileid: node.fileid },
- { dir: node.dirname, openfile: true },
- )
+ emit('files:node:focus', node)
// Close the picker
this.close()
} catch (error) {
- this.logger.error('Error while creating the new file from template', { error })
- showError(this.t('files', 'Unable to create new file from template'))
+ logger.error('Error while creating the new file from template', { error })
+ showError(t('files', 'Unable to create new file from template'))
} finally {
this.loading = false
}