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
---
## 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 (
+
+ );
+}
+
+/** 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({
-
+
);
}
-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}
-
+
);
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()}>
+ {/* 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