mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user