Skip to content

Commit 2034ea9

Browse files
committed
enh(files): Allow to copy files into same directory
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 5de3028 commit 2034ea9

File tree

2 files changed

+114
-33
lines changed

2 files changed

+114
-33
lines changed

apps/files/src/actions/moveOrCopyAction.ts

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,16 @@
2222
import '@nextcloud/dialogs/style.css'
2323
import type { Folder, Node, View } from '@nextcloud/files'
2424
import type { IFilePickerButton } from '@nextcloud/dialogs'
25+
import type { FileStat, ResponseDataDetailed } from 'webdav'
2526
import type { MoveCopyResult } from './moveOrCopyActionUtils'
2627

2728
// eslint-disable-next-line n/no-extraneous-import
2829
import { AxiosError } from 'axios'
2930
import { basename, join } from 'path'
3031
import { emit } from '@nextcloud/event-bus'
31-
import { generateRemoteUrl } from '@nextcloud/router'
32-
import { getCurrentUser } from '@nextcloud/auth'
3332
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
34-
import { Permission, FileAction, FileType, NodeStatus } from '@nextcloud/files'
33+
import { Permission, FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind } from '@nextcloud/files'
3534
import { translate as t } from '@nextcloud/l10n'
36-
import axios from '@nextcloud/axios'
3735
import Vue from 'vue'
3836

3937
import CopyIconSvg from '@mdi/svg/svg/folder-multiple.svg?raw'
@@ -69,6 +67,30 @@ const getActionForNodes = (nodes: Node[]): MoveCopyAction => {
6967
* @return {Promise<void>} A promise that resolves when the copy/move is done
7068
*/
7169
export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => {
70+
/**
71+
* Create an unique name for a node
72+
* @param node Node that is copied
73+
* @param otherNodes Other nodes in the target directory to check for unique name
74+
* @return Either the node basename, if unique, or the name with a `(copy N)` suffix that is unique
75+
*/
76+
const makeUniqueName = (node: Node, otherNodes: Node[]|FileStat[]) => {
77+
const basename = node.basename.slice(0, node.basename.lastIndexOf('.'))
78+
let index = 0
79+
80+
const currentName = () => {
81+
switch (index) {
82+
case 0: return node.basename
83+
case 1: return `${basename} (copy)${node.extension ?? ''}`
84+
default: return `${basename} ${t('files', '(copy %n)', undefined, index)}${node.extension ?? ''}` // TRANSLATORS: Meaning it is the n'th copy of a file
85+
}
86+
}
87+
88+
while (otherNodes.some((other: Node|FileStat) => currentName() === other.basename)) {
89+
index += 1
90+
}
91+
return currentName()
92+
}
93+
7294
if (!destination) {
7395
return
7496
}
@@ -77,7 +99,8 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
7799
throw new Error(t('files', 'Destination is not a folder'))
78100
}
79101

80-
if (node.dirname === destination.path) {
102+
// Do not allow to MOVE a node to the same folder it is already located
103+
if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
81104
throw new Error(t('files', 'This file/folder is already in that directory'))
82105
}
83106

@@ -86,33 +109,43 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
86109
* node: /foo/bar/file.txt -> path = /foo/bar
87110
* destination: /foo
88111
* Allow move of /foo does not start with /foo/bar so allow
112+
* But allow copy a file to the same directory
89113
*/
90-
if (destination.path.startsWith(node.path)) {
114+
if (destination.path.startsWith(node.path) && destination.path !== node.path) {
91115
throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
92116
}
93117

94-
const relativePath = join(destination.path, node.basename)
95-
const destinationUrl = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}${relativePath}`)
96-
97118
// Set loading state
98119
Vue.set(node, 'status', NodeStatus.LOADING)
99120

100121
const queue = getQueue()
101122
return await queue.add(async () => {
102123
try {
103-
await axios({
104-
method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE',
105-
url: node.encodedSource,
106-
headers: {
107-
Destination: encodeURI(destinationUrl),
108-
Overwrite: overwrite ? undefined : 'F',
109-
},
110-
})
124+
const client = davGetClient()
125+
const currentPath = join(davRootPath, node.path)
126+
const destinationPath = join(davRootPath, destination.path)
111127

112-
// If we're moving, update the node
113-
// if we're copying, we don't need to update the node
114-
// the view will refresh itself
115-
if (method === MoveCopyAction.MOVE) {
128+
if (method === MoveCopyAction.COPY) {
129+
let target = node.basename
130+
// If we do not allow overwriting than find an unique name
131+
if (!overwrite) {
132+
const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
133+
target = makeUniqueName(node, otherNodes)
134+
}
135+
await client.copyFile(currentPath, join(destinationPath, target))
136+
// If the node is copied into current directory the view needs to be updated
137+
if (node.dirname === destination.path) {
138+
const { data } = await client.stat(
139+
join(destinationPath, target),
140+
{
141+
details: true,
142+
data: davGetDefaultPropfind(),
143+
},
144+
) as ResponseDataDetailed<FileStat>
145+
emit('files:node:created', davResultToNode(data))
146+
}
147+
} else {
148+
await client.moveFile(currentPath, join(destinationPath, node.basename))
116149
// Delete the node as it will be fetched again
117150
// when navigating to the destination folder
118151
emit('files:node:deleted', node)
@@ -129,6 +162,7 @@ export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, meth
129162
throw new Error(error.message)
130163
}
131164
}
165+
logger.debug(error as Error)
132166
throw new Error()
133167
} finally {
134168
Vue.set(node, 'status', undefined)
@@ -165,16 +199,6 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
165199
const dirnames = nodes.map(node => node.dirname)
166200
const paths = nodes.map(node => node.path)
167201

168-
if (dirnames.includes(path)) {
169-
// This file/folder is already in that directory
170-
return buttons
171-
}
172-
173-
if (paths.includes(path)) {
174-
// You cannot move a file/folder onto itself
175-
return buttons
176-
}
177-
178202
if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
179203
buttons.push({
180204
label: target ? t('files', 'Copy to {target}', { target }) : t('files', 'Copy'),
@@ -189,6 +213,17 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
189213
})
190214
}
191215

216+
// Invalid MOVE targets (but valid copy targets)
217+
if (dirnames.includes(path)) {
218+
// This file/folder is already in that directory
219+
return buttons
220+
}
221+
222+
if (paths.includes(path)) {
223+
// You cannot move a file/folder onto itself
224+
return buttons
225+
}
226+
192227
if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
193228
buttons.push({
194229
label: target ? t('files', 'Move to {target}', { target }) : t('files', 'Move'),
@@ -207,7 +242,8 @@ const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', nodes:
207242
})
208243

209244
const picker = filePicker.build()
210-
picker.pick().catch(() => {
245+
picker.pick().catch((error) => {
246+
logger.debug(error as Error)
211247
reject(new Error(t('files', 'Cancelled move or copy operation')))
212248
})
213249
})
@@ -236,7 +272,13 @@ export const action = new FileAction({
236272

237273
async exec(node: Node, view: View, dir: string) {
238274
const action = getActionForNodes([node])
239-
const result = await openFilePickerForAction(action, dir, [node])
275+
let result
276+
try {
277+
result = await openFilePickerForAction(action, dir, [node])
278+
} catch (e) {
279+
logger.error(e as Error)
280+
return false
281+
}
240282
try {
241283
await handleCopyMoveNodeTo(node, result.destination, result.action)
242284
return true

cypress/e2e/files/files_copy-move.cy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,43 @@ describe('Files: Move or copy files', { testIsolation: true }, () => {
131131
getRowForFile('new-folder').should('be.visible')
132132
getRowForFile('original.txt').should('be.visible')
133133
})
134+
135+
it('Can copy a file to same folder', () => {
136+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
137+
cy.login(currentUser)
138+
cy.visit('/apps/files')
139+
140+
// intercept the copy so we can wait for it
141+
cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
142+
143+
getRowForFile('original.txt').should('be.visible')
144+
triggerActionForFile('original.txt', 'move-copy')
145+
146+
// click copy
147+
cy.get('.file-picker').contains('button', 'Copy').should('be.visible').click()
148+
149+
cy.wait('@copyFile')
150+
getRowForFile('original.txt').should('be.visible')
151+
getRowForFile('original (copy).txt').should('be.visible')
152+
})
153+
154+
it('Can copy a file multiple times to same folder', () => {
155+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original.txt')
156+
cy.uploadContent(currentUser, new Blob(), 'text/plain', '/original (copy).txt')
157+
cy.login(currentUser)
158+
cy.visit('/apps/files')
159+
160+
// intercept the copy so we can wait for it
161+
cy.intercept('COPY', /\/remote.php\/dav\/files\//).as('copyFile')
162+
163+
getRowForFile('original.txt').should('be.visible')
164+
triggerActionForFile('original.txt', 'move-copy')
165+
166+
// click copy
167+
cy.get('.file-picker').contains('button', 'Copy').should('be.visible').click()
168+
169+
cy.wait('@copyFile')
170+
getRowForFile('original.txt').should('be.visible')
171+
getRowForFile('original (copy 2).txt').should('be.visible')
172+
})
134173
})

0 commit comments

Comments
 (0)