From 50d34fcca5e778ec9dd66ec5c6d2048a23a35a2e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:47:05 +0000 Subject: [PATCH] Add download, rename and duplicate to the file actions menu (#7536) # Description of Changes Adds expanded dropdown menu for download, rename and duplicate image image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../public/locales/en-US/translation.toml | 15 + .../core/components/filesPage/FileGrid.tsx | 552 +++++++++--------- .../components/filesPage/FileManagerView.tsx | 101 ++++ .../core/components/shared/FileSidebar.tsx | 137 +++++ .../components/shared/FileSidebarFileItem.css | 22 + .../components/shared/FileSidebarFileItem.tsx | 351 +++++++---- .../shared/RenameFileDialog.stories.tsx | 39 ++ .../components/shared/RenameFileDialog.tsx | 140 +++++ .../core/components/shared/WorkbenchBar.tsx | 6 +- .../editor/src/core/hooks/useFileHandler.ts | 4 + .../tests/stubbed/file-actions-menu.spec.ts | 167 ++++++ .../src/core/utils/duplicateFile.test.ts | 130 +++++ .../editor/src/core/utils/duplicateFile.ts | 85 +++ frontend/editor/src/core/utils/fileUtils.ts | 11 + 14 files changed, 1373 insertions(+), 387 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.tsx create mode 100644 frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts create mode 100644 frontend/editor/src/core/utils/duplicateFile.test.ts create mode 100644 frontend/editor/src/core/utils/duplicateFile.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 01db314031..c2287ace9c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3897,8 +3897,10 @@ collapse = "Collapse sidebar" customizeGroups = "Customize groups" dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it." dataLostTitle = "File data is unavailable" +downloadFailed = "Download failed" dropHint = "Open files to get started" dropToAdd = "Drop files to add" +duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" @@ -3918,8 +3920,10 @@ closeViewer = "Close viewer" dataLost = "Data lost" dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it." delete = "Delete" +duplicate = "Duplicate" moreActions = "More actions" openInViewer = "Open in viewer" +rename = "Rename" savedToServer = "Saved to server" updateOnServer = "Update on server" uploadToServer = "Upload to server" @@ -3931,6 +3935,14 @@ reset = "Show all" subtitle = "Show or hide categories in the files sidebar." title = "Sidebar categories" +[fileSidebar.rename] +cancel = "Cancel" +error = "Could not rename the file." +illegalCharacters = "A file name can't contain \\ / : * ? \" < > |" +label = "File name" +save = "Rename" +title = "Rename file" + [filesPage] addToWorkspace = "Add to workspace" addToWorkspaceCount = "Add {{count}} to workspace" @@ -3972,6 +3984,7 @@ downloadAll = "Download all" downloadVersion = "Download this version" dropOverlay = "Drop files to upload" dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder." +duplicate = "Duplicate" file = "File" fileInfo = "File info" fileMenu = "File actions" @@ -4087,6 +4100,8 @@ cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud." cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud." deleteFolderFailed = "Could not delete folder." deleteFolderFailedDetail = "Could not delete folder: {{message}}" +downloadFailed = "Could not download the file." +duplicateFailed = "Could not duplicate the file." folderAppearanceFailed = "Could not update folder appearance." folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}" moveFilesFailed = "Could not move files." diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index e54964c592..7fd2eda133 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -13,6 +13,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import HistoryIcon from "@mui/icons-material/History"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; @@ -36,6 +37,8 @@ import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; @@ -83,6 +86,12 @@ interface FileGridProps { onSaveToServer?: (file: StirlingFileStub) => void; /** Open the version-history modal for a file (only when it has >1 version). */ onVersionHistory?: (file: StirlingFileStub) => void; + /** Download a copy (desktop: save a copy). */ + onDownloadFile?: (file: StirlingFileStub) => void; + /** Open the rename dialog for a file. */ + onRenameFile?: (file: StirlingFileStub) => void; + /** Save a second copy of the file into the library. */ + onDuplicateFile?: (file: StirlingFileStub) => void; /** When set, the Save to server item renders disabled with this tooltip. */ saveToServerDisabledReason?: string | null; /** When supplied the list-view column headers become sortable. */ @@ -366,24 +375,21 @@ function EmptyState({ ); } -function GridView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, -}: FileGridProps) { +function GridView(props: FileGridProps) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); return (
{entries.map((entry) => { @@ -424,22 +430,7 @@ function GridView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onDoubleClick={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -626,7 +617,222 @@ function PolicyBadges({ fileId }: { fileId: string }) { return ; } -interface FileCardProps { +/** Per-file actions. Shared verbatim by the grid card and the list row, and + * kept in step with the file sidebar's kebab so both surfaces offer the same. */ +interface FileActionsMenuProps { + file: StirlingFileStub; + triggerRef: React.RefObject; + onOpen: () => void; + onMove: () => void; + onRemove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onSaveToServer?: () => void; + onVersionHistory?: () => void; + saveToServerDisabledReason?: string | null; +} + +function FileActionsMenu({ + file, + triggerRef, + onOpen, + onMove, + onRemove, + onDownload, + onRename, + onDuplicate, + onSaveToServer, + onVersionHistory, + saveToServerDisabledReason, +}: FileActionsMenuProps) { + const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; + const showSaveToServer = + Boolean(onSaveToServer) && file.remoteStorageId == null; + const showVersionHistory = + Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1; + return ( + + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + data-testid="file-card-actions" + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onOpen(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + data-testid="file-menu-move-to" + > + {t("filesPage.moveTo", "Move to…")} + + + {(onDownload || onRename || onDuplicate) && } + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + data-testid="file-menu-download" + > + {terminology.download} + + )} + {onRename && ( + } + onClick={(e) => { + e.stopPropagation(); + onRename(); + }} + data-testid="file-menu-rename" + > + {t("filesPage.rename", "Rename")} + + )} + {onDuplicate && ( + } + onClick={(e) => { + e.stopPropagation(); + onDuplicate(); + }} + data-testid="file-menu-duplicate" + > + {t("filesPage.duplicate", "Duplicate")} + + )} + + {(showSaveToServer || showVersionHistory) && } + {/* Per-file Save to server; shown for local-only files. When + storage is off it stays visible but disabled with a tooltip. */} + {showSaveToServer && onSaveToServer && ( + + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + style={ + saveToServerDisabledReason + ? { pointerEvents: "auto" } + : undefined + } + > + {t("filesPage.saveToServer", "Save to server")} + + + )} + {showVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} + + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + + ); +} + +/** Binds one file's kebab handlers, so grid and list wire them identically. */ +function useFileMenuHandlers( + props: FileGridProps, +): (file: StirlingFileStub) => FileMenuHandlers { + const { + selectedFileIds, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, + onVersionHistory, + onDownloadFile, + onRenameFile, + onDuplicateFile, + saveToServerDisabledReason, + } = props; + return (file: StirlingFileStub) => ({ + onRemove: () => onRemoveFiles([file.id]), + // A move acts on the whole selection when this file is part of it. + onMove: () => + onPromptMoveFiles( + selectedFileIds.has(file.id) ? Array.from(selectedFileIds) : [file.id], + ), + onDownload: onDownloadFile ? () => onDownloadFile(file) : undefined, + onRename: onRenameFile ? () => onRenameFile(file) : undefined, + onDuplicate: onDuplicateFile ? () => onDuplicateFile(file) : undefined, + onSaveToServer: onSaveToServer ? () => onSaveToServer(file) : undefined, + onVersionHistory: onVersionHistory + ? () => onVersionHistory(file) + : undefined, + saveToServerDisabledReason, + }); +} + +/** Per-file kebab handlers, shared by the card and row wrappers. */ +interface FileMenuHandlers { + onRemove: () => void; + onMove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** Open the version-history modal; shown only when file has >1 version. */ + onVersionHistory?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +interface FileCardProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -637,14 +843,6 @@ interface FileCardProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileCard({ @@ -656,11 +854,7 @@ function FileCard({ multiSelectActive, onClick, onDoubleClick, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileCardProps) { const { t } = useTranslation(); const cardRef = useRef(null); @@ -790,120 +984,40 @@ function FileCard({
- - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onDoubleClick(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - data-testid="file-menu-move-to" - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - +
); } -function ListView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onSetSelection, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - sortMode, - onChangeSortMode, -}: FileGridProps & { - sortMode?: FilesPageSortMode; - onChangeSortMode?: (next: FilesPageSortMode) => void; -}) { +function ListView( + props: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; + }, +) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + sortMode, + onChangeSortMode, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); const { t } = useTranslation(); // Tri-state header checkbox state - computed from current entries. @@ -1029,22 +1143,7 @@ function ListView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onOpen={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -1241,7 +1340,7 @@ function FolderRow({ ); } -interface FileRowProps { +interface FileRowProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -1251,14 +1350,6 @@ interface FileRowProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onOpen: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileRow({ @@ -1270,11 +1361,7 @@ function FileRow({ multiSelectActive, onClick, onOpen, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileRowProps) { const { t } = useTranslation(); const kebabRef = useRef(null); @@ -1414,91 +1501,12 @@ function FileRow({ {fileSize} {fileDate} - - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onOpen(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - + ); diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index a0b0c6cfa1..e87b348780 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -71,6 +71,10 @@ import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; +import { duplicateStoredFile } from "@app/utils/duplicateFile"; +import { downloadFileFromStorage } from "@app/utils/downloadUtils"; +import { fileStorage } from "@app/services/fileStorage"; import { materializeServerStubs } from "@app/services/fileSyncService"; import { FILES_PAGE_DRAG_TYPE, @@ -794,6 +798,92 @@ export default function FileManagerView() { [removeFiles], ); + // ─── per-file kebab: download / rename / duplicate ─────────────────────── + // Same actions the file sidebar's kebab offers, so both surfaces match. + + /** Cloud-only rows hold no bytes; pull them local before acting on them. */ + const localCopyOf = useCallback( + async (file: StirlingFileStub): Promise => { + const [materialized] = await materializeServerStubs([file], { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + return materialized ?? null; + }, + [fileActions], + ); + + const handleDownloadFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + await downloadFileFromStorage(local); + } catch (err) { + console.error("[FilesPage] Download failed", err); + folders.setError( + t("filesPage.error.downloadFailed", "Could not download the file."), + ); + } + }, + [localCopyOf, folders, t], + ); + + const handleDuplicateFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + const copyId = await duplicateStoredFile( + local, + allFiles.map((f) => f.name), + addFiles, + ); + if (!copyId) { + throw new Error(`File "${local.name}" not found in storage`); + } + await refresh(); + } catch (err) { + console.error("[FilesPage] Duplicate failed", err); + folders.setError( + t("filesPage.error.duplicateFailed", "Could not duplicate the file."), + ); + } + }, + [localCopyOf, allFiles, addFiles, refresh, folders, t], + ); + + const [renameTarget, setRenameTarget] = useState( + null, + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write; the workbench copy (if any) is updated in the same breath. + const handleConfirmRename = useCallback( + async (name: string) => { + const file = renameTarget; + if (!file) return; + const local = await localCopyOf(file); + if (!local) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${local.size}|${local.lastModified}`; + const saved = await fileStorage.updateFileMetadata(local.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(local.id, { name, quickKey }); + setRenameTarget(null); + await refresh(); + }, + [renameTarget, localCopyOf, fileActions, refresh, t], + ); + // ─── derived UI bits ──────────────────────────────────────────────────── const currentFolderRecord = currentFolderId ? (foldersById.get(currentFolderId) ?? null) @@ -1503,6 +1593,9 @@ export default function FileManagerView() { onPromptMoveFiles={promptMoveFiles} onSaveToServer={(file) => setSaveToServerTarget([file])} onVersionHistory={(file) => setVersionHistoryFile(file)} + onDownloadFile={handleDownloadFile} + onRenameFile={setRenameTarget} + onDuplicateFile={handleDuplicateFile} saveToServerDisabledReason={saveToServerDisabledReason} // Center-of-grid CTAs when the empty state shows - same // handlers the corner header buttons use so behaviour @@ -1662,6 +1755,14 @@ export default function FileManagerView() { onConfirm={confirmRemoveFiles} /> + {/* Rename (opened from the card kebab). */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Version journey in a modal (opened from the card kebab). */} ( const [deleteTarget, setDeleteTarget] = useState( null, ); + // Kebab "Rename" target; drives RenameFileDialog. + const [renameTarget, setRenameTarget] = useState( + null, + ); // Storage gate: only offer Save-to-cloud when the server allows it and // the user is signed in (guests have no cloud library). const storageEnabled = config?.storageEnabled === true && !isAnonymous; @@ -417,6 +425,121 @@ const FileSidebar = forwardRef( [allFileStubs], ); + const warnDataUnavailable = useCallback(() => { + alert({ + alertType: "warning", + title: t("fileSidebar.dataLostTitle", "File data is unavailable"), + body: t( + "fileSidebar.dataLostBody", + "This browser lost this file's contents. Upload it again to keep working with it.", + ), + expandable: false, + durationMs: 6000, + }); + }, [t]); + + // Kebab: download a copy (desktop saves via the native dialog). Routed + // through the policy wrapper so export policies enforce here too. + const handleDownload = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + const file = await fileStorage.getStirlingFile(fileId); + if (!file) { + warnDataUnavailable(); + return; + } + try { + await downloadFileWithPolicy({ + data: file, + filename: stub?.name ?? file.name, + fileId: fileId as string, + }); + } catch (error) { + console.error("[FileSidebar] Download failed:", error); + alert({ + alertType: "error", + title: t("fileSidebar.downloadFailed", "Download failed"), + body: error instanceof Error ? error.message : String(error), + expandable: false, + }); + } + }, + [allFileStubs, warnDataUnavailable, t], + ); + + // Kebab: copy the file into the library under a free "(copy)" name. + const handleDuplicate = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (!stub) return; + try { + const copyId = await duplicateStoredFile( + stub, + allFileStubs.map((s) => s.name), + addFiles, + ); + if (!copyId) { + warnDataUnavailable(); + return; + } + await refreshStubs(); + } catch (error) { + console.error("[FileSidebar] Duplicate failed:", error); + alert({ + alertType: "error", + title: t("fileSidebar.duplicateFailed", "Could not duplicate file"), + body: error instanceof Error ? error.message : String(error), + expandable: false, + }); + } + }, + [allFileStubs, addFiles, refreshStubs, warnDataUnavailable, t], + ); + + // Kebab: open the rename dialog for this one file. + const handleRename = useCallback( + (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (stub) setRenameTarget(stub); + }, + [allFileStubs], + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write - storage first, then the workbench copy if the file is open. + const handleConfirmRename = useCallback( + async (name: string) => { + const stub = renameTarget; + if (!stub) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${stub.size}|${stub.lastModified}`; + const saved = await fileStorage.updateFileMetadata(stub.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(stub.id, { name, quickKey }); + setRenameTarget(null); + await refreshStubs(); + }, + [renameTarget, fileActions, refreshStubs, t], + ); + + // Desktop-only; a no-op stub on web, where this stays hidden. + const { canOpenInNewWindow, openInNewWindow } = useOpenInNewWindow(); + const handleOpenInNewWindow = useCallback( + (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (stub) openInNewWindow(stub); + }, + [allFileStubs, openInNewWindow], + ); + // Once a pending file lands in state, open it in the viewer. useEffect(() => { if (!pendingViewFileId) return; @@ -773,6 +896,12 @@ const FileSidebar = forwardRef( onFolderClick={openWatchedFolder} policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES} onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete} + onDownload={handleDownload} + onRename={isWatchedFoldersActive ? undefined : handleRename} + onDuplicate={isWatchedFoldersActive ? undefined : handleDuplicate} + onOpenInNewWindow={ + canOpenInNewWindow(stub) ? handleOpenInNewWindow : undefined + } onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud} canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"} isUploadedToCloud={fileOrigin === "cloud"} @@ -1222,6 +1351,14 @@ const FileSidebar = forwardRef( onChanged={refreshStubs} /> + {/* Kebab "Rename" dialog. */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Cloud-aware delete choice (only opened for cloud-uploaded files). */} void; + /** Download a copy (desktop: save a copy) from the kebab menu. */ + onDownload?: (fileId: FileId) => void; + /** Rename the file from the kebab menu. */ + onRename?: (fileId: FileId) => void; + /** Save a second copy of the file into the library. */ + onDuplicate?: (fileId: FileId) => void; + /** Desktop only: open the file in its own window. Omit where unsupported. */ + onOpenInNewWindow?: (fileId: FileId) => void; /** Save to cloud from the kebab menu. */ onSaveToCloud?: (fileId: FileId) => void; /** Whether the upload-to-server menu item is offered (storage on, signed in). */ @@ -171,6 +184,46 @@ export interface FileItemProps { const MAX_VISIBLE_FOLDER_TAGS = 2; +/** One kebab row. `disabledReason`, when set, greys the row out and says why. */ +function FileMenuItem({ + disabledReason, + icon, + color, + onClick, + children, +}: { + disabledReason?: React.ReactNode; + icon: React.ReactNode; + color?: string; + onClick: (e: React.MouseEvent) => void; + children: React.ReactNode; +}) { + return ( + + {/* Disabled items swallow pointer events, so the tooltip needs a live wrapper. */} +
+ { + e.stopPropagation(); + onClick(e); + }} + > + {children} + +
+
+ ); +} + // Memoized: sidebar rows bail out unless THEIR props change, so one file's // update (e.g. a new version landing) re-renders one row, not the whole list. export const FileItem = React.memo(function FileItem({ @@ -192,6 +245,10 @@ export const FileItem = React.memo(function FileItem({ policies = [], primaryLabel, onDelete, + onDownload, + onRename, + onDuplicate, + onOpenInNewWindow, onSaveToCloud, canSaveToCloud = false, isUploadedToCloud = false, @@ -199,9 +256,14 @@ export const FileItem = React.memo(function FileItem({ hasVersionHistory = false, }: FileItemProps) { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; const ext = getFileExtension(name); const dateLabel = lastModified ? formatFileDate(lastModified) : ""; const typeLabel = ext ? ext.toUpperCase() : "File"; + const metaLine = [typeLabel, size ? formatFileSize(size) : null, dateLabel] + .filter(Boolean) + .join(" · "); const policyEnforcing = policies.some((p) => p.enforcing); const enforcingTooltip = (action: string): React.ReactNode => ( @@ -220,6 +282,25 @@ export const FileItem = React.memo(function FileItem({ ); + // Why an action can't run right now: a policy is rewriting the file, or its + // bytes are gone. `needsBytes` actions are the ones that read the file. + const blockedReason = ( + action: string, + needsBytes = true, + ): React.ReactNode | null => { + if (policyEnforcing) return enforcingTooltip(action); + if (needsBytes && dataUnavailable) + return t( + "fileSidebar.fileItem.dataLostTooltip", + "This browser lost this file's contents. Upload it again to keep working with it.", + ); + return null; + }; + + const viewerLabel = isViewedInViewer + ? t("fileSidebar.fileItem.closeViewer", "Close viewer") + : t("fileSidebar.fileItem.openInViewer", "Open in viewer"); + const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS); const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS); @@ -233,6 +314,7 @@ export const FileItem = React.memo(function FileItem({ const itemRef = useRef(null); const [hoverRect, setHoverRect] = useState(null); + const [menuOpened, setMenuOpened] = useState(false); const handleMouseEnter = useCallback(() => { setHoverRect(itemRef.current?.getBoundingClientRect() ?? null); @@ -240,9 +322,10 @@ export const FileItem = React.memo(function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); - // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready. + // The kebab suppresses it - two cards floating off one row read as a glitch. const thumbPos = - hoverRect && resolvedThumbnail + hoverRect && resolvedThumbnail && !menuOpened ? { top: hoverRect.top + hoverRect.height / 2, left: hoverRect.right + 10, @@ -389,11 +472,7 @@ export const FileItem = React.memo(function FileItem({ onEyeClick(fileId, e); }} tabIndex={-1} - aria-label={ - isViewedInViewer - ? t("fileSidebar.fileItem.closeViewer", "Close viewer") - : t("fileSidebar.fileItem.openInViewer", "Open in viewer") - } + aria-label={viewerLabel} > - {(onDelete || - (canSaveToCloud && onSaveToCloud) || - (hasVersionHistory && onVersionHistory)) && ( - - - e.stopPropagation()} - tabIndex={-1} - aria-label={t( - "fileSidebar.fileItem.moreActions", - "More actions", - )} - > - - - - e.stopPropagation()}> - {hasVersionHistory && onVersionHistory && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(fileId); - }} - > - {t( - "fileSidebar.fileItem.versionHistory", - "Version history", - )} - + + + e.stopPropagation()} + tabIndex={-1} + aria-label={t( + "fileSidebar.fileItem.moreActions", + "More actions", )} - {canSaveToCloud && - onSaveToCloud && - (() => { - const uploadLabel = isUploadedToCloud - ? t( - "fileSidebar.fileItem.updateOnServer", - "Update on server", - ) - : t( - "fileSidebar.fileItem.uploadToServer", - "Upload to server", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {uploadLabel} - -
-
- ); - })()} - {onDelete && - (() => { - const deleteLabel = t( - "fileSidebar.fileItem.delete", - "Delete", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {deleteLabel} - -
-
- ); - })()} - -
- )} + > + + + + e.stopPropagation()}> + {/* Rows truncate long names; the menu header is where the whole + name (and the size the row has no space for) is readable. */} + + {name} + + {metaLine} + + + + + ) : ( + + ) + } + onClick={(e) => onEyeClick(fileId, e)} + > + {viewerLabel} + + + {onOpenInNewWindow && ( + } + onClick={() => onOpenInNewWindow(fileId)} + > + {t("openInNewWindow", "Open in new window")} + + )} + + {(onDownload || onRename || onDuplicate) && } + + {onDownload && ( + } + onClick={() => onDownload(fileId)} + > + {terminology.download} + + )} + + {onRename && ( + } + onClick={() => onRename(fileId)} + > + {t("fileSidebar.fileItem.rename", "Rename")} + + )} + + {onDuplicate && ( + } + onClick={() => onDuplicate(fileId)} + > + {t("fileSidebar.fileItem.duplicate", "Duplicate")} + + )} + + {((canSaveToCloud && onSaveToCloud) || + (hasVersionHistory && onVersionHistory)) && } + + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + ); + return ( + } + onClick={() => onSaveToCloud(fileId)} + > + {uploadLabel} + + ); + })()} + + {hasVersionHistory && onVersionHistory && ( + } + onClick={() => onVersionHistory(fileId)} + > + {t("fileSidebar.fileItem.versionHistory", "Version history")} + + )} + + {onDelete && ( + <> + + } + onClick={() => onDelete(fileId)} + > + {t("fileSidebar.fileItem.delete", "Delete")} + + + )} + +
diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx new file mode 100644 index 0000000000..9413db41b0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; + +const meta = { + title: "Shared/RenameFileDialog", + component: RenameFileDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + fileName: "Q3 invoice bundle.pdf", + onClose: () => {}, + onSubmit: () => {}, + }, +}; + +export const NoExtension: Story = { + args: { + opened: true, + fileName: "scanned-document", + onClose: () => {}, + onSubmit: () => {}, + }, +}; + +export const SaveFails: Story = { + args: { + opened: true, + fileName: "contract.pdf", + onClose: () => {}, + onSubmit: () => { + throw new Error("Could not rename the file."); + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx new file mode 100644 index 0000000000..83e00f6ba3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +import { Button } from "@app/ui/Button"; +import { splitFileName } from "@app/utils/fileUtils"; + +/** Characters Windows/macOS reject in a filename, which is also what a download saves as. */ +const ILLEGAL_NAME_CHARS = /[\\/:*?"<>|]/; + +interface RenameFileDialogProps { + opened: boolean; + /** Current name, including its extension. */ + fileName: string; + onClose: () => void; + /** Gets the new full name. Throwing keeps the dialog open with the message. */ + onSubmit: (name: string) => void | Promise; +} + +/** + * Renames one library file. Only the base name is editable - the extension is + * shown but fixed, so a rename can't leave the file claiming the wrong type. + */ +export function RenameFileDialog({ + opened, + fileName, + onClose, + onSubmit, +}: RenameFileDialogProps) { + const { t } = useTranslation(); + const [base, extension] = splitFileName(fileName); + const [value, setValue] = useState(base); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setValue(base); + setSubmitting(false); + setError(null); + } + }, [opened, base]); + + const submit = async () => { + const nextBase = value.trim(); + if (!nextBase) return; + if (ILLEGAL_NAME_CHARS.test(nextBase)) { + setError( + t( + "fileSidebar.rename.illegalCharacters", + "A file name can't contain \\ / : * ? \" < > |", + ), + ); + return; + } + const nextName = `${nextBase}${extension}`; + if (nextName === fileName) { + onClose(); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit(nextName); + onClose(); + } catch (err) { + // Stay open on failure: closing would look like the rename worked. + setError( + err instanceof Error + ? err.message + : t("fileSidebar.rename.error", "Could not rename the file."), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + setValue(e.currentTarget.value)} + onFocus={(e) => e.currentTarget.select()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={200} + aria-label={t("fileSidebar.rename.label", "File name")} + rightSection={ + extension ? ( + + {extension} + + ) : undefined + } + rightSectionWidth={extension ? extension.length * 8 + 12 : undefined} + rightSectionPointerEvents="none" + /> + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 241d658bad..ad38fb1974 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -229,7 +229,8 @@ export default function WorkbenchBar({ try { const result = await downloadFile({ data: new Blob([buffer], { type: "application/pdf" }), - filename: fileToExport.name, + // Stub name, not File.name: a rename only writes the stub. + filename: stub?.name ?? fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, }); @@ -281,7 +282,8 @@ export default function WorkbenchBar({ try { const result = await downloadRaw({ data: enforced[idx], - filename: file.name, + // Stub name, not File.name: a rename only writes the stub. + filename: stub?.name ?? file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, }); diff --git a/frontend/editor/src/core/hooks/useFileHandler.ts b/frontend/editor/src/core/hooks/useFileHandler.ts index 2f26648fd1..6fcbcb2350 100644 --- a/frontend/editor/src/core/hooks/useFileHandler.ts +++ b/frontend/editor/src/core/hooks/useFileHandler.ts @@ -13,6 +13,10 @@ export const useFileHandler = () => { selectFiles?: boolean; /** Persist to IDB without dispatching to workspace state. */ skipWorkspaceDispatch?: boolean; + /** Defaults to true; false keeps an archive intact (e.g. duplicating one). */ + autoUnzip?: boolean; + /** Skip the upload metric - the file isn't new to the system (e.g. a copy). */ + skipUploadTracking?: boolean; } = {}, ): Promise => { // Merge default options with passed options - passed options take precedence diff --git a/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts new file mode 100644 index 0000000000..cafc188462 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts @@ -0,0 +1,167 @@ +import path from "path"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// Per-file actions live behind a kebab on two surfaces - the file sidebar and +// the My Files grid. They must offer the same file actions on both. + +const SAMPLE = path.join(import.meta.dirname, "../test-fixtures/sample.pdf"); + +const rows = (page: Page) => page.locator(".file-sidebar-file-item"); + +/** Hover the first sidebar row (the kebab only shows on hover) and open it. */ +async function openKebab(page: Page): Promise { + const row = rows(page).first(); + await row.hover(); + await row.locator(".file-sidebar-kebab-btn").click(); + await expect(page.getByRole("menu")).toBeVisible(); +} + +test("the kebab lists the file's actions under its full name", async ({ + page, +}) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + + const menu = page.getByRole("menu"); + await expect(menu.locator(".file-sidebar-kebab-header-name")).toHaveText( + "sample.pdf", + ); + // Type · size · date - the row itself has no space for the size. + await expect(menu.locator(".file-sidebar-kebab-header-meta")).toContainText( + "PDF", + ); + // A lone upload lands in the viewer, so the toggle offers the way out. + await expect( + menu.getByRole("menuitem", { name: "Close viewer" }), + ).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); +}); + +test("Download saves the file under its current name", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download" }).click(); + expect((await download).suggestedFilename()).toBe("sample.pdf"); +}); + +test("Rename updates the row and survives a reload", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + // Only the base name is editable; the extension is re-applied on submit. + const input = page.getByLabel("File name"); + await expect(input).toHaveValue("sample"); + await input.fill("quarterly report"); + await page.getByRole("button", { name: "Rename" }).click(); + + await expect(rows(page).first()).toContainText("quarterly report.pdf"); + + // The name is metadata in IndexedDB, so it must outlive the page. + await page.reload(); + await expect(rows(page).first()).toContainText("quarterly report.pdf"); +}); + +test("Duplicate adds a copy to the library", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + await expect(rows(page)).toHaveCount(2); + await expect(rows(page).filter({ hasText: "sample (copy).pdf" })).toHaveCount( + 1, + ); +}); + +test("a duplicate inherits the original's classification", async ({ page }) => { + // The copy is byte-identical, so it must land in the same category group - + // it inherits the label rather than waiting on the idle backfill to re-parse. + await uploadFiles( + page, + path.join( + import.meta.dirname, + "../test-fixtures/classification/classified_invoice.pdf", + ), + ); + const financial = page + .locator(".file-sidebar-group") + .filter({ hasText: "Financial" }); + await expect(financial).toBeVisible({ timeout: 15_000 }); + + await openKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + // The copy carries the label straight away - its row shows the label chip... + const copy = rows(page) + .filter({ hasText: "classified_invoice (copy).pdf" }) + .first(); + await expect(copy).toContainText("Invoice", { timeout: 5_000 }); + // ...and it counts towards the same category group. + await expect(financial.locator(".file-sidebar-group-count")).toHaveText("2"); +}); + +// ─── My Files grid: the same actions, same behaviour ──────────────────────── + +const cards = (page: Page) => page.locator(".files-page-card:not(.is-folder)"); + +/** Upload a file, cross to My Files, and open the card's kebab. */ +async function openCardKebab(page: Page): Promise { + await uploadFiles(page, SAMPLE); + await page.getByTestId("my-files-button").click(); + const card = cards(page).filter({ hasText: "sample.pdf" }).first(); + await expect(card).toBeVisible(); + await card.getByRole("button", { name: /File actions/i }).click(); + await expect(page.getByRole("menu")).toBeVisible(); +} + +test("My Files offers the same file actions as the sidebar", async ({ + page, +}) => { + await openCardKebab(page); + + const menu = page.getByRole("menu"); + await expect( + menu.getByRole("menuitem", { name: "Add to workspace" }), + ).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Move to…" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); +}); + +test("My Files Download saves the file under its current name", async ({ + page, +}) => { + await openCardKebab(page); + + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download" }).click(); + expect((await download).suggestedFilename()).toBe("sample.pdf"); +}); + +test("My Files Rename updates the card", async ({ page }) => { + await openCardKebab(page); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + await page.getByLabel("File name").fill("statement"); + await page.getByRole("button", { name: "Rename" }).click(); + + await expect(cards(page).filter({ hasText: "statement.pdf" })).toHaveCount(1); +}); + +test("My Files Duplicate adds a copy", async ({ page }) => { + await openCardKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + await expect( + cards(page).filter({ hasText: "sample (copy).pdf" }), + ).toHaveCount(1); +}); diff --git a/frontend/editor/src/core/utils/duplicateFile.test.ts b/frontend/editor/src/core/utils/duplicateFile.test.ts new file mode 100644 index 0000000000..88c7cce686 --- /dev/null +++ b/frontend/editor/src/core/utils/duplicateFile.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FolderId } from "@app/types/folder"; + +const getStirlingFile = vi.fn(); +const updateFileMetadata = vi.fn(); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: (...args: unknown[]) => getStirlingFile(...args), + updateFileMetadata: (...args: unknown[]) => updateFileMetadata(...args), + }, +})); + +const { copyNameFor, duplicateStoredFile } = + await import("@app/utils/duplicateFile"); + +/** + * A duplicate is byte-identical to its source, so everything derived from those + * bytes (classification labels, thumbnail) and its place in the library (folder) + * is inherited rather than re-derived. These lock that contract. + */ + +const stub = (extra: Partial = {}): StirlingFileStub => + ({ + id: "src-id" as FileId, + name: "report.pdf", + size: 10, + type: "application/pdf", + lastModified: 1, + ...extra, + }) as StirlingFileStub; + +const asStirlingFile = (name: string): StirlingFile => + Object.assign(new File(["%PDF-1.7"], name, { type: "application/pdf" }), { + fileId: "new-id" as FileId, + quickKey: "k", + }) as StirlingFile; + +type AddFilesOptions = { + selectFiles?: boolean; + skipWorkspaceDispatch?: boolean; + autoUnzip?: boolean; + skipUploadTracking?: boolean; +}; +const addFiles = vi.fn< + (files: File[], options: AddFilesOptions) => Promise +>(async () => [asStirlingFile("report (copy).pdf")]); + +beforeEach(() => { + vi.clearAllMocks(); + getStirlingFile.mockResolvedValue(asStirlingFile("report.pdf")); + updateFileMetadata.mockResolvedValue(true); + addFiles.mockResolvedValue([asStirlingFile("report (copy).pdf")]); +}); + +describe("copyNameFor", () => { + it("keeps the extension and counts up until the name is free", () => { + expect(copyNameFor("report.pdf", [])).toBe("report (copy).pdf"); + expect(copyNameFor("report.pdf", ["report (copy).pdf"])).toBe( + "report (copy 2).pdf", + ); + expect( + copyNameFor("report.pdf", ["report (copy).pdf", "report (copy 2).pdf"]), + ).toBe("report (copy 3).pdf"); + }); + + it("handles a name with no extension", () => { + expect(copyNameFor("scan", [])).toBe("scan (copy)"); + }); +}); + +describe("duplicateStoredFile", () => { + it("inherits labels, folder and a persisted thumbnail", async () => { + const id = await duplicateStoredFile( + stub({ + classificationLabels: ["invoice"], + folderId: "folder-1" as FolderId, + thumbnailUrl: "data:image/png;base64,AAA", + }), + [], + addFiles, + ); + + expect(id).toBe("new-id"); + const [, updates] = updateFileMetadata.mock.calls[0]; + expect(updates.classificationLabels).toEqual(["invoice"]); + expect(updates.folderId).toBe("folder-1"); + expect(updates.thumbnail).toBe("data:image/png;base64,AAA"); + expect(updates.thumbnailStoredAt).toEqual(expect.any(Number)); + }); + + it("drops a blob: thumbnail - it would be dead on the next load", async () => { + await duplicateStoredFile( + stub({ classificationLabels: ["invoice"], thumbnailUrl: "blob:abc" }), + [], + addFiles, + ); + + const [, updates] = updateFileMetadata.mock.calls[0]; + expect(updates).not.toHaveProperty("thumbnail"); + }); + + it("writes nothing when the source has no derived metadata", async () => { + await duplicateStoredFile(stub(), [], addFiles); + + expect(updateFileMetadata).not.toHaveBeenCalled(); + }); + + it("copies the library entry without touching the workbench or metrics", async () => { + await duplicateStoredFile(stub(), ["report (copy).pdf"], addFiles); + + const [files, options] = addFiles.mock.calls[0]; + expect(files[0].name).toBe("report (copy 2).pdf"); + expect(options).toMatchObject({ + selectFiles: false, + skipWorkspaceDispatch: true, + autoUnzip: false, + skipUploadTracking: true, + }); + }); + + it("returns null when the source bytes are gone", async () => { + getStirlingFile.mockResolvedValue(null); + + expect(await duplicateStoredFile(stub(), [], addFiles)).toBeNull(); + expect(addFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/utils/duplicateFile.ts b/frontend/editor/src/core/utils/duplicateFile.ts new file mode 100644 index 0000000000..5bad75b513 --- /dev/null +++ b/frontend/editor/src/core/utils/duplicateFile.ts @@ -0,0 +1,85 @@ +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import { fileStorage } from "@app/services/fileStorage"; +import { splitFileName } from "@app/utils/fileUtils"; + +/** The subset of `useFileHandler().addFiles` a duplicate needs. */ +type AddFilesFn = ( + files: File[], + options: { + selectFiles?: boolean; + skipWorkspaceDispatch?: boolean; + autoUnzip?: boolean; + skipUploadTracking?: boolean; + }, +) => Promise; + +/** "report.pdf" → "report (copy).pdf", counting up until the name is free. */ +export function copyNameFor(name: string, taken: Iterable): string { + const [base, extension] = splitFileName(name); + const used = new Set(taken); + let candidate = `${base} (copy)${extension}`; + for (let n = 2; used.has(candidate); n++) { + candidate = `${base} (copy ${n})${extension}`; + } + return candidate; +} + +/** + * Copies a stored file into the library under a free "(copy)" name. + * + * The copy stays out of the workbench - it's an archive of the current bytes, + * not a file the user asked to work on. That skips the ingest side-effects that + * hang off the workspace dispatch, so the derived metadata is inherited from + * the source instead: the bytes are identical, so its classification labels and + * thumbnail are too, and re-deriving them would only re-parse the same PDF. The + * copy also lands in the source's folder rather than back at the root. + * + * @returns the new file's id, or null if the source has no readable bytes. + */ +export async function duplicateStoredFile( + stub: StirlingFileStub, + existingNames: Iterable, + addFiles: AddFilesFn, +): Promise { + const source = await fileStorage.getStirlingFile(stub.id); + if (!source) return null; + + const [copy] = await addFiles( + [ + new File([source], copyNameFor(stub.name, existingNames), { + type: source.type, + }), + ], + { + selectFiles: false, + skipWorkspaceDispatch: true, + // Duplicating an archive must yield one copy, not its contents scattered + // across the library. + autoUnzip: false, + // A local copy is not a new document entering the system. + skipUploadTracking: true, + }, + ); + if (!copy) return null; + + const inherited: Parameters[1] = {}; + if (stub.classificationLabels) { + inherited.classificationLabels = stub.classificationLabels; + } + // A copy belongs beside its original. Safe to set on a local-only file: the + // server is authoritative for folderId only on files it actually holds. + if (stub.folderId) { + inherited.folderId = stub.folderId; + } + // Blob URLs die with the session, so only a persisted thumbnail is worth + // carrying over; without one the row falls back to lazy regeneration. + if (stub.thumbnailUrl && !stub.thumbnailUrl.startsWith("blob:")) { + inherited.thumbnail = stub.thumbnailUrl; + inherited.thumbnailStoredAt = Date.now(); + } + if (Object.keys(inherited).length > 0) { + await fileStorage.updateFileMetadata(copy.fileId, inherited); + } + return copy.fileId; +} diff --git a/frontend/editor/src/core/utils/fileUtils.ts b/frontend/editor/src/core/utils/fileUtils.ts index 6a256a9073..13002db52e 100644 --- a/frontend/editor/src/core/utils/fileUtils.ts +++ b/frontend/editor/src/core/utils/fileUtils.ts @@ -73,6 +73,17 @@ export function getFilenameWithoutExtension( return preserveCase ? withoutExtension : withoutExtension.toLowerCase(); } +/** + * Splits a filename into its base and extension, keeping the dot on the + * extension so `base + extension` round-trips. A name with no extension (or a + * leading-dot name like ".env") gets an empty extension. + * @example splitFileName('report.pdf') // ['report', '.pdf'] + */ +export function splitFileName(name: string): [string, string] { + const dot = name.lastIndexOf("."); + return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; +} + /** * Checks if a file is a PDF based on extension and MIME type * @param file - File or file-like object with name and type properties