diff --git a/src/components/FolderPicker/types.ts b/src/components/FolderPicker/types.ts index 2ce25edb09..b63a74aa7a 100644 --- a/src/components/FolderPicker/types.ts +++ b/src/components/FolderPicker/types.ts @@ -1,6 +1,15 @@ import { IOCozyFile, NextcloudFile } from 'cozy-client/types/types' -export type File = IOCozyFile | NextcloudFile +export type File = (IOCozyFile | NextcloudFile) & { + cozyMetadata?: { + createdOn?: string + } + attributes?: { + cozyMetadata?: { + createdOn?: string + } + } +} export interface FolderPickerEntry { _id?: string @@ -11,4 +20,5 @@ export interface FolderPickerEntry { dir_id?: string class?: string path?: string + driveId?: string } diff --git a/src/contexts/ClipboardProvider.tsx b/src/contexts/ClipboardProvider.tsx index 17a76f813e..29dc5527fe 100644 --- a/src/contexts/ClipboardProvider.tsx +++ b/src/contexts/ClipboardProvider.tsx @@ -32,7 +32,11 @@ interface ClipboardState { interface ClipboardContextValue { clipboardData: ClipboardState - copyFiles: (files: IOCozyFile[], sourceFolderIds?: Set) => void + copyFiles: ( + files: IOCozyFile[], + sourceFolderIds?: Set, + sourceDirectory?: IOCozyFile + ) => void cutFiles: ( files: IOCozyFile[], sourceFolderIds?: Set, @@ -170,10 +174,14 @@ const ClipboardProvider: React.FC = ({ children }) => { const [state, dispatch] = useReducer(clipboardReducer, initialState) const copyFiles = useCallback( - (files: IOCozyFile[], sourceFolderIds?: Set) => { + ( + files: IOCozyFile[], + sourceFolderIds?: Set, + sourceDirectory?: IOCozyFile + ) => { dispatch({ type: COPY_FILES, - payload: { files, sourceFolderIds } + payload: { files, sourceFolderIds, sourceDirectory } }) }, [] diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 692a21f7ba..c5cf42405e 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -101,6 +101,21 @@ declare module 'cozy-client/dist/models/file' { filename: string, driveId: string ) => Promise + export const moveRelateToSharedDrive: ( + client: import('cozy-client/types/CozyClient').CozyClient, + source: { + instance?: string + sharing_id?: string + file_id?: string + dir_id?: string + }, + dest: { + instance?: string + sharing_id?: string + dir_id: string + }, + isCopy?: boolean + ) => Promise } declare module 'cozy-client/dist/models/note' { diff --git a/src/hooks/useKeyboardShortcuts.spec.jsx b/src/hooks/useKeyboardShortcuts.spec.jsx index 0257b9cafb..1f2e9998cd 100644 --- a/src/hooks/useKeyboardShortcuts.spec.jsx +++ b/src/hooks/useKeyboardShortcuts.spec.jsx @@ -247,7 +247,8 @@ describe('useKeyboardShortcuts', () => { expect(mockCopyFiles).toHaveBeenCalledWith( mockSelectedItems, - new Set(['parent-folder-1', 'parent-folder-2']) + new Set(['parent-folder-1', 'parent-folder-2']), + mockCurrentFolder ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.items_copied_2', @@ -311,7 +312,8 @@ describe('useKeyboardShortcuts', () => { expect(mockCopyFiles).toHaveBeenCalledWith( mockSelectedItems.filter(item => item.type === 'file'), - new Set(['parent-folder-1', 'parent-folder-2']) + new Set(['parent-folder-1', 'parent-folder-2']), + mockCurrentFolder ) }) }) @@ -761,7 +763,8 @@ describe('useKeyboardShortcuts', () => { expect(mockCopyFiles).toHaveBeenCalledWith( sharedDriveFiles, - new Set(['shared-folder-1']) + new Set(['shared-folder-1']), + sharedDriveFolder ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.item_copied', diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 14ce0be6b4..507216fbbe 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -108,14 +108,26 @@ export const useKeyboardShortcuts = ({ return } - copyFiles(filesToCopy, new Set(parentFolderIds)) + copyFiles( + filesToCopy, + new Set(parentFolderIds), + currentFolder as IOCozyFile + ) const message = filesToCopy.length === 1 ? t('alert.item_copied') : t('alert.items_copied', { count: filesToCopy.length }) showAlert({ message, severity: 'success' }) clearSelection() - }, [allowCopy, selectedItems, copyFiles, showAlert, t, clearSelection]) + }, [ + allowCopy, + selectedItems, + currentFolder, + copyFiles, + showAlert, + t, + clearSelection + ]) const handleCut = useCallback(() => { if (!selectedItems.length) return diff --git a/src/modules/duplicate/components/DuplicateModal.tsx b/src/modules/duplicate/components/DuplicateModal.tsx index 34f58d8b15..6a29fdd231 100644 --- a/src/modules/duplicate/components/DuplicateModal.tsx +++ b/src/modules/duplicate/components/DuplicateModal.tsx @@ -2,7 +2,6 @@ import React, { FC, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useClient } from 'cozy-client' -import { copy } from 'cozy-client/dist/models/file' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' @@ -12,6 +11,7 @@ import { File, FolderPickerEntry } from '@/components/FolderPicker/types' import { ROOT_DIR_ID } from '@/constants/config' import { useCancelable } from '@/modules/move/hooks/useCancelable' import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers' +import { executeDuplicate } from '@/modules/paste' interface DuplicateModalProps { entries: FolderPickerEntry[] @@ -19,6 +19,7 @@ interface DuplicateModalProps { onClose: () => void | Promise showNextcloudFolder?: boolean isPublic?: boolean + showSharedDriveFolder?: boolean } const DuplicateModal: FC = ({ @@ -26,7 +27,8 @@ const DuplicateModal: FC = ({ currentFolder, onClose, showNextcloudFolder, - isPublic + isPublic, + showSharedDriveFolder }) => { const { t } = useI18n() const { showAlert } = useAlert() @@ -41,7 +43,9 @@ const DuplicateModal: FC = ({ setBusy(true) await Promise.all( entries.map(async entry => { - await registerCancelable(copy(client, entry as Partial, folder)) + await registerCancelable( + executeDuplicate(client, entry as File, currentFolder, folder) + ) }) ) @@ -80,8 +84,11 @@ const DuplicateModal: FC = ({ * This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates */ const refreshNextcloudQueries = (folder: File): void => { + const sourceAccount = folder.cozyMetadata?.sourceAccount + if (!sourceAccount || !folder.path) return + const queryId = computeNextcloudFolderQueryId({ - sourceAccount: folder.cozyMetadata?.sourceAccount, + sourceAccount, path: folder.path }) void client?.resetQuery(queryId) @@ -90,6 +97,7 @@ const DuplicateModal: FC = ({ return ( ( } /> } /> } /> + } /> ) : null} diff --git a/src/modules/paste/index.d.ts b/src/modules/paste/index.d.ts new file mode 100644 index 0000000000..65406f9552 --- /dev/null +++ b/src/modules/paste/index.d.ts @@ -0,0 +1,85 @@ +import { CozyClient } from 'cozy-client' + +import { File } from '@/components/FolderPicker/types' + +export interface PasteOperationOptions { + showAlert?: (alert: { message: string; severity: string }) => void + t?: (key: string, params?: Record) => string + sharingContext?: { + getSharedParentPath?: (path: string) => string + hasSharedParent?: (path: string) => boolean + byDocId?: Record + } + showMoveValidationModal?: ( + modalType: string, + file: File, + targetFolder: File, + onConfirm: () => void, + onCancel: () => void + ) => void + isPublic?: boolean +} + +export interface PasteOperationResult { + success: boolean + file: File + error?: Error + operation: string +} + +/** + * Executes a move operation for files or folders. + * Automatically detects if it's a shared drive operation and uses the appropriate API. + * + * @param client - The cozy client instance + * @param entry - The file or folder to move + * @param sourceDirectory - The source directory containing the entry + * @param destDirectory - The destination directory + * @param force - Whether to force the move operation + * @returns The result of the move operation + */ +export function executeMove( + client: CozyClient, + entry: File, + sourceDirectory: File, + destDirectory: File, + force?: boolean +): Promise + +/** + * Executes a duplicate operation for files or folders. + * Automatically detects if it's a shared drive operation and uses the appropriate API. + * + * @param client - The cozy client instance + * @param entry - The file or folder to duplicate + * @param sourceDirectory - The source directory containing the entry + * @param destDirectory - The destination directory + * @returns The result of the duplicate operation + */ +export function executeDuplicate( + client: CozyClient, + entry: File, + sourceDirectory: File, + destDirectory: File +): Promise + +/** + * Handles paste operations (copy or cut) for multiple files/folders. + * Processes each file individually and handles validation, conflicts, and sharing permissions. + * + * @param client - The cozy client instance + * @param files - Array of files/folders to paste + * @param operation - The paste operation ('copy' or 'cut') + * @param sourceDirectory - The source directory containing the files + * @param targetFolder - The target folder for the paste operation + * @param options - Additional options + * @returns Array of operation results with success/failure status + */ +export function handlePasteOperation( + client: CozyClient, + files: File[], + operation: string | null, + sourceDirectory: File, + targetFolder: File, + options?: PasteOperationOptions +): Promise diff --git a/src/modules/paste/index.js b/src/modules/paste/index.js index a70c76e55d..e6bf5bfc21 100644 --- a/src/modules/paste/index.js +++ b/src/modules/paste/index.js @@ -85,6 +85,36 @@ export const executeMove = async ( }) } +/** + * Executes a duplicate operation for files or folders. + * Automatically detects if it's a shared drive operation and uses the appropriate API. + * + * @param {CozyClient} client - The cozy client instance + * @param {import('@/components/FolderPicker/types').File} entry - The file or folder to move + * @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the entry + * @param {import('@/components/FolderPicker/types').File} destDirectory - The destination directory + * @param {boolean} [force=false] - Whether to force the move operation + * @returns {Promise} The result of the move operation + */ +export const executeDuplicate = async ( + client, + entry, + sourceDirectory, + destDirectory +) => { + const isSharedDriveOperation = entry.driveId || destDirectory.driveId + if (isSharedDriveOperation) { + return await executeSharedDriveMoveOrCopy( + client, + entry, + sourceDirectory, + destDirectory, + 'copy' + ) + } + return await copy(client, entry, destDirectory) +} + /** * Handles paste operations (copy or cut) for multiple files/folders. * Processes each file individually and handles validation, conflicts, and sharing permissions. @@ -137,6 +167,7 @@ export const handlePasteOperation = async ( const result = await handleDuplicateWithValidation( client, file, + sourceDirectory, targetFolder, { showAlert, t } ) @@ -184,12 +215,18 @@ export const handlePasteOperation = async ( const handleDuplicateWithValidation = async ( client, file, + sourceDirectory, targetFolder, options = {} ) => { const { showAlert, t } = options - const result = await copy(client, file, targetFolder) + const result = await executeDuplicate( + client, + file, + sourceDirectory, + targetFolder + ) const isCopyingInsideNextcloud = targetFolder._type === NEXTCLOUD_FILE_ID if (isCopyingInsideNextcloud) { diff --git a/src/modules/paste/index.spec.js b/src/modules/paste/index.spec.js index 3b0e51994a..40ca941d65 100644 --- a/src/modules/paste/index.spec.js +++ b/src/modules/paste/index.spec.js @@ -360,7 +360,7 @@ describe('handlePasteOperation', () => { expect(result[1].success).toBe(false) expect(result[0].error).toBeInstanceOf(Error) expect(result[1].error).toBeInstanceOf(Error) - expect(copy).toHaveBeenCalledTimes(2) + expect(copy).not.toHaveBeenCalled() }) it('should handle missing options', async () => { diff --git a/src/modules/shareddrives/components/SharedDriveFolderBody.jsx b/src/modules/shareddrives/components/SharedDriveFolderBody.jsx index 2ab2497c63..157f6e5036 100644 --- a/src/modules/shareddrives/components/SharedDriveFolderBody.jsx +++ b/src/modules/shareddrives/components/SharedDriveFolderBody.jsx @@ -12,6 +12,7 @@ import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' import { useModalContext } from '@/lib/ModalContext' import { download, infos, versions, rename, trash, hr } from '@/modules/actions' +import { duplicateTo } from '@/modules/actions/components/duplicateTo' import { moveTo } from '@/modules/actions/components/moveTo' import { FolderBody } from '@/modules/folder/components/FolderBody' @@ -53,7 +54,7 @@ const SharedDriveFolderBody = ({ refresh } const actions = makeActions( - [download, hr, rename, moveTo, infos, hr, versions, hr, trash], + [download, hr, rename, moveTo, duplicateTo, infos, hr, versions, hr, trash], actionsOptions ) diff --git a/src/modules/views/Folder/FolderDuplicateView.tsx b/src/modules/views/Folder/FolderDuplicateView.tsx index d6c94508c5..eb052ba46f 100644 --- a/src/modules/views/Folder/FolderDuplicateView.tsx +++ b/src/modules/views/Folder/FolderDuplicateView.tsx @@ -8,6 +8,7 @@ import flag from 'cozy-flags' import { LoaderModal } from '@/components/LoaderModal' import useDisplayedFolder from '@/hooks/useDisplayedFolder' import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal' +import { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives' import { buildParentsByIdsQuery } from '@/queries' const FolderDuplicateView: FC = () => { @@ -16,6 +17,7 @@ const FolderDuplicateView: FC = () => { state: { fileIds?: string[] } } const { displayedFolder } = useDisplayedFolder() + const { sharedDrives } = useSharedDrives() const hasFileIds = state.fileIds != undefined @@ -39,6 +41,7 @@ const FolderDuplicateView: FC = () => { return ( 0} currentFolder={displayedFolder} entries={fileResult.data} onClose={onClose} diff --git a/src/modules/views/Folder/SharedDriveDuplicateView.tsx b/src/modules/views/Folder/SharedDriveDuplicateView.tsx new file mode 100644 index 0000000000..b5ea7936fc --- /dev/null +++ b/src/modules/views/Folder/SharedDriveDuplicateView.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' + +import flag from 'cozy-flags' + +import { LoaderModal } from '@/components/LoaderModal' +import useDisplayedFolder from '@/hooks/useDisplayedFolder' +import { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal' +import { useQueryMultipleSharedDriveFolders } from '@/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders' + +const SharedDriveDuplicateView: FC = () => { + const navigate = useNavigate() + const { state } = useLocation() as { + state: { fileIds?: string[] } + } + const { displayedFolder } = useDisplayedFolder() + + const hasFileIds = state.fileIds != undefined + + const { sharedDriveResults } = useQueryMultipleSharedDriveFolders({ + folderIds: state.fileIds ?? [], + driveId: displayedFolder?.driveId ?? '' + }) + + if (!hasFileIds) { + return + } + + if (sharedDriveResults && displayedFolder) { + const onClose = (): void => { + navigate('..', { replace: true }) + } + + return ( + + ) + } + + return +} + +export { SharedDriveDuplicateView } diff --git a/src/modules/views/SharedDrive/SharedDriveFolderView.jsx b/src/modules/views/SharedDrive/SharedDriveFolderView.jsx index 1f29054aad..2558ce5a8c 100644 --- a/src/modules/views/SharedDrive/SharedDriveFolderView.jsx +++ b/src/modules/views/SharedDrive/SharedDriveFolderView.jsx @@ -49,7 +49,7 @@ const SharedDriveFolderView = () => { items: sharedDriveResult?.included || [], sharingContext, allowCut: canWriteToCurrentFolder, - allowCopy: false, + allowCopy: canWriteToCurrentFolder, pushModal, popModal, refresh