Add download, rename and duplicate to the file actions menu (#7536)

# Description of Changes
Adds expanded dropdown menu for download, rename and duplicate 

<img width="560" height="380" alt="image"
src="https://github.com/user-attachments/assets/84464f0a-46e1-42cf-8098-26f77888710f"
/>

<img width="560" height="480" alt="image"
src="https://github.com/user-attachments/assets/ed871f98-7f89-4560-869e-9ff514000b6f"
/>

---

## 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.
This commit is contained in:
Anthony Stirling
2026-08-20 11:47:05 +00:00
committed by GitHub
parent 4791d558c5
commit 50d34fcca5
14 changed files with 1373 additions and 387 deletions
@@ -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."
@@ -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 (
<div className="files-page-grid" role="list">
{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 <PolicyBadgeRow policies={badges} />;
}
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<HTMLButtonElement | null>;
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 (
<Menu shadow="md" position="bottom-end" withinPortal width={220}>
<Menu.Target>
<ActionIcon
ref={triggerRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
data-testid="file-menu-move-to"
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{(onDownload || onRename || onDuplicate) && <Menu.Divider />}
{onDownload && (
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
data-testid="file-menu-download"
>
{terminology.download}
</Menu.Item>
)}
{onRename && (
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRename();
}}
data-testid="file-menu-rename"
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
)}
{onDuplicate && (
<Menu.Item
leftSection={<ContentCopyOutlinedIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
data-testid="file-menu-duplicate"
>
{t("filesPage.duplicate", "Duplicate")}
</Menu.Item>
)}
{(showSaveToServer || showVersionHistory) && <Menu.Divider />}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{showSaveToServer && onSaveToServer && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
{showVersionHistory && onVersionHistory && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
}}
>
{t("filesPage.versionHistory", "Version history")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
/** 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<HTMLDivElement>(null);
@@ -790,120 +984,40 @@ function FileCard({
</div>
</div>
<div className="files-page-card-actions">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDoubleClick();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
data-testid="file-menu-move-to"
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* 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 && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
}}
>
{t("filesPage.versionHistory", "Version history")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<FileActionsMenu
file={file}
triggerRef={kebabRef}
onOpen={onDoubleClick}
{...menuHandlers}
/>
</div>
</div>
);
}
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<HTMLButtonElement>(null);
@@ -1414,91 +1501,12 @@ function FileRow({
<span role="gridcell">{fileSize}</span>
<span role="gridcell">{fileDate}</span>
<span role="gridcell">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* 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 && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
}}
>
{t("filesPage.versionHistory", "Version history")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<FileActionsMenu
file={file}
triggerRef={kebabRef}
onOpen={onOpen}
{...menuHandlers}
/>
</span>
</div>
);
@@ -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<StirlingFileStub | null> => {
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<StirlingFileStub | null>(
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). */}
<RenameFileDialog
opened={Boolean(renameTarget)}
fileName={renameTarget?.name ?? ""}
onClose={() => setRenameTarget(null)}
onSubmit={handleConfirmRename}
/>
{/* Version journey in a modal (opened from the card kebab). */}
<VersionHistoryModal
opened={Boolean(versionHistoryFile)}
@@ -55,12 +55,16 @@ import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerMo
import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
import { RenameFileDialog } from "@app/components/shared/RenameFileDialog";
import { duplicateStoredFile } from "@app/utils/duplicateFile";
import { SidebarChecklistSlot } from "@app/components/shared/SidebarChecklistSlot";
import {
deleteServerFile,
type DeleteScope,
} from "@app/services/serverStorageDelete";
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
import { useOpenInNewWindow } from "@app/extensions/openInNewWindow";
import { alert } from "@app/components/toast";
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
@@ -276,6 +280,10 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const [deleteTarget, setDeleteTarget] = useState<StirlingFileStub | null>(
null,
);
// Kebab "Rename" target; drives RenameFileDialog.
const [renameTarget, setRenameTarget] = useState<StirlingFileStub | null>(
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<HTMLDivElement, FileSidebarProps>(
[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<HTMLDivElement, FileSidebarProps>(
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<HTMLDivElement, FileSidebarProps>(
onChanged={refreshStubs}
/>
{/* Kebab "Rename" dialog. */}
<RenameFileDialog
opened={Boolean(renameTarget)}
fileName={renameTarget?.name ?? ""}
onClose={() => setRenameTarget(null)}
onSubmit={handleConfirmRename}
/>
{/* Cloud-aware delete choice (only opened for cloud-uploaded files). */}
<DeleteFilesDialog
opened={Boolean(deleteTarget)}
@@ -373,6 +373,28 @@
color: var(--c-text);
}
/* Menu header: the full file name the row had to truncate, plus its size. */
.file-sidebar-kebab-header {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 6px;
}
.file-sidebar-kebab-header-name {
color: var(--c-text);
font-size: 12px;
font-weight: 600;
line-height: 1.3;
overflow-wrap: anywhere;
}
.file-sidebar-kebab-header-meta {
color: var(--c-text-subtle);
font-size: 11px;
text-transform: none;
}
/* ---- Date group headers ---- */
.file-sidebar-date-group-header {
font-size: 11px;
@@ -12,6 +12,9 @@ import CloudDoneIcon from "@mui/icons-material/CloudDone";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
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 type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import {
@@ -20,7 +23,9 @@ import {
} from "@app/components/shared/PolicyBadges";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { formatFileSize, IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
import "@app/components/shared/FileSidebarFileItem.css";
export function getFileExtension(name: string): string {
@@ -154,6 +159,14 @@ export interface FileItemProps {
primaryLabel?: string;
/** Delete (local only) from the kebab menu. Omit to hide the menu's delete. */
onDelete?: (fileId: FileId) => 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 (
<Tooltip
label={disabledReason}
disabled={!disabledReason}
position="left"
offset={6}
withArrow
>
{/* Disabled items swallow pointer events, so the tooltip needs a live wrapper. */}
<div>
<Menu.Item
disabled={Boolean(disabledReason)}
color={color}
leftSection={icon}
onClick={(e) => {
e.stopPropagation();
onClick(e);
}}
>
{children}
</Menu.Item>
</div>
</Tooltip>
);
}
// 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({
</Stack>
);
// 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<HTMLDivElement>(null);
const [hoverRect, setHoverRect] = useState<DOMRect | null>(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}
>
<VisibilityOutlinedIcon
className="file-sidebar-eye-open"
@@ -404,112 +483,158 @@ export const FileItem = React.memo(function FileItem({
sx={{ fontSize: "1.1rem" }}
/>
</ActionIcon>
{(onDelete ||
(canSaveToCloud && onSaveToCloud) ||
(hasVersionHistory && onVersionHistory)) && (
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
className="file-sidebar-kebab-btn"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
aria-label={t(
"fileSidebar.fileItem.moreActions",
"More actions",
)}
>
<MoreVertIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
{hasVersionHistory && onVersionHistory && (
<Menu.Item
leftSection={<HistoryIcon sx={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory(fileId);
}}
>
{t(
"fileSidebar.fileItem.versionHistory",
"Version history",
)}
</Menu.Item>
<Menu
position="bottom-end"
withinPortal
shadow="md"
width={220}
onChange={setMenuOpened}
>
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
className="file-sidebar-kebab-btn"
onClick={(e) => 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 (
<Tooltip
label={enforcingTooltip(uploadLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
leftSection={
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onSaveToCloud(fileId);
}}
>
{uploadLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
{onDelete &&
(() => {
const deleteLabel = t(
"fileSidebar.fileItem.delete",
"Delete",
);
return (
<Tooltip
label={enforcingTooltip(deleteLabel)}
disabled={!policyEnforcing}
position="left"
offset={6}
withArrow
>
<div>
<Menu.Item
disabled={policyEnforcing}
color="red"
leftSection={
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
onDelete(fileId);
}}
>
{deleteLabel}
</Menu.Item>
</div>
</Tooltip>
);
})()}
</Menu.Dropdown>
</Menu>
)}
>
<MoreVertIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onClick={(e) => 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. */}
<Menu.Label className="file-sidebar-kebab-header">
<span className="file-sidebar-kebab-header-name">{name}</span>
<span className="file-sidebar-kebab-header-meta">
{metaLine}
</span>
</Menu.Label>
<FileMenuItem
disabledReason={blockedReason(viewerLabel)}
icon={
isViewedInViewer ? (
<VisibilityOffOutlinedIcon sx={{ fontSize: 16 }} />
) : (
<VisibilityOutlinedIcon sx={{ fontSize: 16 }} />
)
}
onClick={(e) => onEyeClick(fileId, e)}
>
{viewerLabel}
</FileMenuItem>
{onOpenInNewWindow && (
<FileMenuItem
disabledReason={blockedReason(
t("openInNewWindow", "Open in new window"),
)}
icon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={() => onOpenInNewWindow(fileId)}
>
{t("openInNewWindow", "Open in new window")}
</FileMenuItem>
)}
{(onDownload || onRename || onDuplicate) && <Menu.Divider />}
{onDownload && (
<FileMenuItem
disabledReason={blockedReason(terminology.download)}
icon={<DownloadIcon sx={{ fontSize: 16 }} />}
onClick={() => onDownload(fileId)}
>
{terminology.download}
</FileMenuItem>
)}
{onRename && (
<FileMenuItem
disabledReason={blockedReason(
t("fileSidebar.fileItem.rename", "Rename"),
false,
)}
icon={<DriveFileRenameOutlineIcon sx={{ fontSize: 16 }} />}
onClick={() => onRename(fileId)}
>
{t("fileSidebar.fileItem.rename", "Rename")}
</FileMenuItem>
)}
{onDuplicate && (
<FileMenuItem
disabledReason={blockedReason(
t("fileSidebar.fileItem.duplicate", "Duplicate"),
)}
icon={<ContentCopyOutlinedIcon sx={{ fontSize: 16 }} />}
onClick={() => onDuplicate(fileId)}
>
{t("fileSidebar.fileItem.duplicate", "Duplicate")}
</FileMenuItem>
)}
{((canSaveToCloud && onSaveToCloud) ||
(hasVersionHistory && onVersionHistory)) && <Menu.Divider />}
{canSaveToCloud &&
onSaveToCloud &&
(() => {
const uploadLabel = isUploadedToCloud
? t(
"fileSidebar.fileItem.updateOnServer",
"Update on server",
)
: t(
"fileSidebar.fileItem.uploadToServer",
"Upload to server",
);
return (
<FileMenuItem
disabledReason={blockedReason(uploadLabel)}
icon={<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />}
onClick={() => onSaveToCloud(fileId)}
>
{uploadLabel}
</FileMenuItem>
);
})()}
{hasVersionHistory && onVersionHistory && (
<FileMenuItem
disabledReason={blockedReason(
t("fileSidebar.fileItem.versionHistory", "Version history"),
false,
)}
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
onClick={() => onVersionHistory(fileId)}
>
{t("fileSidebar.fileItem.versionHistory", "Version history")}
</FileMenuItem>
)}
{onDelete && (
<>
<Menu.Divider />
<FileMenuItem
disabledReason={blockedReason(
t("fileSidebar.fileItem.delete", "Delete"),
false,
)}
color="red"
icon={<DeleteOutlineIcon sx={{ fontSize: 16 }} />}
onClick={() => onDelete(fileId)}
>
{t("fileSidebar.fileItem.delete", "Delete")}
</FileMenuItem>
</>
)}
</Menu.Dropdown>
</Menu>
</div>
</div>
@@ -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<typeof RenameFileDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
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.");
},
},
};
@@ -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<void>;
}
/**
* 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<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
title={t("fileSidebar.rename.title", "Rename file")}
centered
size="sm"
transitionProps={{ duration: 0 }}
// Mantine zeroes the body's top padding when a header is present, which
// would butt the input's border straight against the header rule.
styles={{ body: { paddingTop: "var(--mantine-spacing-md)" } }}
>
<Stack gap="sm">
<TextInput
autoFocus
value={value}
onChange={(e) => 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 ? (
<span style={{ color: "var(--c-text-subtle)", fontSize: 12 }}>
{extension}
</span>
) : undefined
}
rightSectionWidth={extension ? extension.length * 8 + 12 : undefined}
rightSectionPointerEvents="none"
/>
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="secondary" onClick={onClose}>
{t("fileSidebar.rename.cancel", "Cancel")}
</Button>
<Button
onClick={submit}
loading={submitting}
disabled={!value.trim()}
>
{t("fileSidebar.rename.save", "Rename")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -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,
});
@@ -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<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
@@ -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<void> {
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<void> {
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);
});
@@ -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> = {}): 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<StirlingFile[]>
>(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();
});
});
@@ -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<StirlingFile[]>;
/** "report.pdf" → "report (copy).pdf", counting up until the name is free. */
export function copyNameFor(name: string, taken: Iterable<string>): 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<string>,
addFiles: AddFilesFn,
): Promise<FileId | null> {
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<typeof fileStorage.updateFileMetadata>[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;
}
@@ -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