mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add cloud-aware delete and version history to My Files (#6704)
## /files - Cloud-aware delete: choose device / cloud / both - `/files` left rail always collapsed - Details panel: pinned buttons, collapsible info, smaller preview - Removed redundant Quick view - New i18n keys added to en-US/en-GB ## Sidebar - Sidebar kebab: upload to server, delete, version history (+ cloud badge) - Version history modal everywhere files appear
This commit is contained in:
@@ -3604,6 +3604,7 @@ unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
@@ -3654,7 +3655,13 @@ searchPlaceholder = "Search files..."
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
savedToServer = "Saved to server"
|
||||
updateOnServer = "Update on server"
|
||||
uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
@@ -3671,6 +3678,11 @@ closeDetails = "Close details"
|
||||
create = "Create"
|
||||
cycleBlocked = "Can't move a folder into one of its own subfolders."
|
||||
delete = "Delete"
|
||||
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
|
||||
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
|
||||
deleteFilesError = "Could not delete. Try again."
|
||||
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
|
||||
deleteFilesTitle = "Delete {{count}} file(s)?"
|
||||
deleteFolder = "Delete folder"
|
||||
deleteFolderBody = "Delete folder \"{{name}}\"?"
|
||||
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
|
||||
@@ -3688,6 +3700,7 @@ downloadVersion = "Download this version"
|
||||
dropOverlay = "Drop files to upload"
|
||||
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder."
|
||||
file = "File"
|
||||
fileInfo = "File info"
|
||||
fileMenu = "File actions"
|
||||
folder = "Folder"
|
||||
folderItems = "{{count}} items"
|
||||
@@ -3707,10 +3720,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
|
||||
open = "Open"
|
||||
openVersionInWorkspace = "Open in workspace"
|
||||
originFilter = "Filter by source"
|
||||
quickView = "Quick view"
|
||||
refresh = "Refresh from server"
|
||||
remove = "Delete"
|
||||
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
|
||||
removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renameFolder = "Rename folder"
|
||||
@@ -3735,10 +3746,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
versionHistory = "Version history"
|
||||
versionHistoryEmpty = "This file has no earlier versions."
|
||||
versionOrigin = "Original upload"
|
||||
versionsCount = "{{count}} versions"
|
||||
versionShowHidden = "Show {{count}} earlier versions"
|
||||
viewVersion = "View this version"
|
||||
viewVersionHistory = "Version journey ({{count}})"
|
||||
appearance.colour = "Colour"
|
||||
appearance.icon = "Icon"
|
||||
appearance.title = "Appearance"
|
||||
@@ -3747,6 +3760,12 @@ column.modified = "Modified"
|
||||
column.name = "Name"
|
||||
column.size = "Size"
|
||||
column.type = "Type"
|
||||
deleteScope.cloud = "Cloud only"
|
||||
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
|
||||
deleteScope.device = "This device only"
|
||||
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
|
||||
deleteScope.everywhere = "Everywhere"
|
||||
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
|
||||
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files."
|
||||
empty.newFolderCta = "Create folder"
|
||||
empty.title = "This folder is empty"
|
||||
@@ -3765,6 +3784,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
|
||||
error.folderAppearanceFailed = "Could not update folder appearance."
|
||||
|
||||
@@ -2667,9 +2667,9 @@ agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
|
||||
[chat.input]
|
||||
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analyzing your request..."
|
||||
@@ -3578,6 +3578,7 @@ unzip = "Unzip"
|
||||
updateOnServer = "Update on Server"
|
||||
uploadSelected = "Upload Files"
|
||||
uploadToServer = "Upload to Server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[files]
|
||||
addFiles = "Add files"
|
||||
@@ -3629,7 +3630,13 @@ searchPlaceholder = "Search files..."
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
savedToServer = "Saved to server"
|
||||
updateOnServer = "Update on server"
|
||||
uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
@@ -3646,6 +3653,11 @@ closeDetails = "Close details"
|
||||
create = "Create"
|
||||
cycleBlocked = "Can't move a folder into one of its own subfolders."
|
||||
delete = "Delete"
|
||||
deleteFilesChoiceBody = "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?"
|
||||
deleteFilesCloudBody = "Delete {{count}} file(s) from the cloud? This cannot be undone."
|
||||
deleteFilesError = "Could not delete. Try again."
|
||||
deleteFilesLocalBody = "Delete {{count}} file(s) from this device? This cannot be undone."
|
||||
deleteFilesTitle = "Delete {{count}} file(s)?"
|
||||
deleteFolder = "Delete folder"
|
||||
deleteFolderBody = "Delete folder \"{{name}}\"?"
|
||||
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
|
||||
@@ -3663,6 +3675,7 @@ 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."
|
||||
file = "File"
|
||||
fileInfo = "File info"
|
||||
fileMenu = "File actions"
|
||||
folder = "Folder"
|
||||
folderItems = "{{count}} items"
|
||||
@@ -3682,10 +3695,8 @@ offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disa
|
||||
open = "Open"
|
||||
openVersionInWorkspace = "Open in workspace"
|
||||
originFilter = "Filter by source"
|
||||
quickView = "Quick view"
|
||||
refresh = "Refresh from server"
|
||||
remove = "Delete"
|
||||
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
|
||||
removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renameFolder = "Rename folder"
|
||||
@@ -3710,10 +3721,12 @@ uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put the
|
||||
uploadFromMobile = "Upload from Mobile"
|
||||
versionActions = "Version actions"
|
||||
versionCollapse = "Collapse middle versions"
|
||||
versionHistory = "Version history"
|
||||
versionHistoryEmpty = "This file has no earlier versions."
|
||||
versionOrigin = "Original upload"
|
||||
versionsCount = "{{count}} versions"
|
||||
versionShowHidden = "Show {{count}} earlier versions"
|
||||
viewVersion = "View this version"
|
||||
viewVersionHistory = "Version journey ({{count}})"
|
||||
appearance.colour = "Color"
|
||||
appearance.icon = "Icon"
|
||||
appearance.title = "Appearance"
|
||||
@@ -3722,6 +3735,12 @@ column.modified = "Modified"
|
||||
column.name = "Name"
|
||||
column.size = "Size"
|
||||
column.type = "Type"
|
||||
deleteScope.cloud = "Cloud only"
|
||||
deleteScope.cloudHint = "Deletes from the server. A copy stays on this device."
|
||||
deleteScope.device = "This device only"
|
||||
deleteScope.deviceHint = "Removes the local copy. The cloud copy is kept."
|
||||
deleteScope.everywhere = "Everywhere"
|
||||
deleteScope.everywhereHint = "Deletes the file from this device and the cloud."
|
||||
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
|
||||
empty.newFolderCta = "Create folder"
|
||||
empty.title = "This folder is empty"
|
||||
@@ -3740,6 +3759,7 @@ empty.sharedByMe.hint = "Create a share link or invite a teammate from any of yo
|
||||
empty.sharedByMe.title = "You haven't shared any files yet"
|
||||
error.actionFailed = "Could not {{action}}."
|
||||
error.actionFailedDetail = "Could not {{action}}: {{message}}"
|
||||
error.cloudDeleteFailed = "Couldn't delete {{count}} file(s) from the cloud."
|
||||
error.deleteFolderFailed = "Could not delete folder."
|
||||
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
|
||||
error.folderAppearanceFailed = "Could not update folder appearance."
|
||||
|
||||
@@ -18,6 +18,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
@@ -40,6 +41,7 @@ import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWith
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
@@ -288,6 +290,8 @@ const FileEditorThumbnail = ({
|
||||
fileActions,
|
||||
]);
|
||||
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const hoverActions = useMemo<HoverAction[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -384,6 +388,16 @@ const FileEditorThumbnail = ({
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||
},
|
||||
{
|
||||
id: "versionHistory",
|
||||
icon: <HistoryIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.versionHistory", "Version history"),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setShowVersionHistory(true);
|
||||
},
|
||||
hidden: (file.versionNumber ?? 1) <= 1,
|
||||
},
|
||||
{
|
||||
id: "close",
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
@@ -399,6 +413,7 @@ const FileEditorThumbnail = ({
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
file.versionNumber,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
@@ -671,6 +686,11 @@ const FileEditorThumbnail = ({
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
<VersionHistoryModal
|
||||
opened={showVersionHistory}
|
||||
onClose={() => setShowVersionHistory(false)}
|
||||
file={file}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Button, Group, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { DeleteScope } from "@app/services/serverStorageDelete";
|
||||
|
||||
interface DeleteFilesDialogProps {
|
||||
opened: boolean;
|
||||
/** Files targeted for deletion (resolved stubs). */
|
||||
files: StirlingFileStub[];
|
||||
onClose: () => void;
|
||||
/** Perform the delete for the chosen scope; may throw to surface an error. */
|
||||
onConfirm: (scope: DeleteScope) => Promise<void>;
|
||||
}
|
||||
|
||||
/** An ephemeral stub (server-/shared-) has no local IndexedDB row. */
|
||||
function hasLocalCopy(stub: StirlingFileStub): boolean {
|
||||
const id = String(stub.id);
|
||||
return !id.startsWith("server-") && !id.startsWith("shared-");
|
||||
}
|
||||
|
||||
/** Only the owner can delete a file from cloud storage (backend is owner-only). */
|
||||
function hasDeletableCloudCopy(stub: StirlingFileStub): boolean {
|
||||
return (
|
||||
typeof stub.remoteStorageId === "number" &&
|
||||
stub.remoteOwnedByCurrentUser === true
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteFilesDialog({
|
||||
opened,
|
||||
files,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteFilesDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [scope, setScope] = useState<DeleteScope>("everywhere");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { total, localCount, cloudCount } = useMemo(() => {
|
||||
let local = 0;
|
||||
let cloud = 0;
|
||||
for (const f of files) {
|
||||
if (hasLocalCopy(f)) local += 1;
|
||||
if (hasDeletableCloudCopy(f)) cloud += 1;
|
||||
}
|
||||
return { total: files.length, localCount: local, cloudCount: cloud };
|
||||
}, [files]);
|
||||
|
||||
// "Choice" mode only when files live in both places - otherwise a plain confirm.
|
||||
const showChoice = localCount > 0 && cloudCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setScope("everywhere");
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const runConfirm = async (chosen: DeleteScope) => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm(chosen);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("filesPage.deleteFilesError", "Could not delete. Try again."),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Single fixed scope when there's no real choice: pure-local -> device,
|
||||
// pure-cloud (no local copy) -> everywhere (nothing local to keep).
|
||||
const fixedScope: DeleteScope = cloudCount === 0 ? "device" : "everywhere";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("filesPage.deleteFilesTitle", "Delete {{count}} file(s)?", {
|
||||
count: total,
|
||||
})}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{showChoice ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"filesPage.deleteFilesChoiceBody",
|
||||
"Some of these files are saved both on this device and in the cloud. Where should they be deleted from?",
|
||||
)}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scope}
|
||||
onChange={(v) => setScope(v as DeleteScope)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Radio
|
||||
value="device"
|
||||
disabled={submitting}
|
||||
label={t("filesPage.deleteScope.device", "This device only")}
|
||||
description={t(
|
||||
"filesPage.deleteScope.deviceHint",
|
||||
"Removes the local copy. The cloud copy is kept.",
|
||||
)}
|
||||
/>
|
||||
<Radio
|
||||
value="cloud"
|
||||
disabled={submitting}
|
||||
label={t("filesPage.deleteScope.cloud", "Cloud only")}
|
||||
description={t(
|
||||
"filesPage.deleteScope.cloudHint",
|
||||
"Deletes from the server. A copy stays on this device.",
|
||||
)}
|
||||
/>
|
||||
<Radio
|
||||
value="everywhere"
|
||||
disabled={submitting}
|
||||
label={t("filesPage.deleteScope.everywhere", "Everywhere")}
|
||||
description={t(
|
||||
"filesPage.deleteScope.everywhereHint",
|
||||
"Deletes the file from this device and the cloud.",
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm">
|
||||
{cloudCount === 0
|
||||
? t(
|
||||
"filesPage.deleteFilesLocalBody",
|
||||
"Delete {{count}} file(s) from this device? This cannot be undone.",
|
||||
{ count: total },
|
||||
)
|
||||
: t(
|
||||
"filesPage.deleteFilesCloudBody",
|
||||
"Delete {{count}} file(s) from the cloud? This cannot be undone.",
|
||||
{ count: total },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<ErrorOutlineIcon fontSize="small" />}
|
||||
variant="light"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
{t("filesPage.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={submitting}
|
||||
onClick={() => runConfirm(showChoice ? scope : fixedScope)}
|
||||
>
|
||||
{t("filesPage.delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Badge, Button, Menu, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Button, Tooltip } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
|
||||
import { FileId, ToolOperation } from "@app/types/file";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
@@ -27,6 +24,10 @@ import ToolChain from "@app/components/shared/ToolChain";
|
||||
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import {
|
||||
VersionTimeline,
|
||||
DetailField,
|
||||
} from "@app/components/filesPage/VersionTimeline";
|
||||
|
||||
interface FileDetailsPanelProps {
|
||||
selectedFileIds: FileId[];
|
||||
@@ -34,13 +35,16 @@ interface FileDetailsPanelProps {
|
||||
currentFolder: FolderRecord | null;
|
||||
onClose: () => void;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onQuickView: (fileId: FileId) => void;
|
||||
onMove: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
/** Save to server; only shown when at least one selected file is local-only. */
|
||||
onSaveToServer?: (files: StirlingFileStub[]) => void;
|
||||
/** When set, Save to server renders disabled with this tooltip (storage off). */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
/** On small screens, show a compact "Version journey" button instead of the
|
||||
* full inline timeline (which opens onOpenVersionHistory). */
|
||||
compactVersions?: boolean;
|
||||
onOpenVersionHistory?: () => void;
|
||||
}
|
||||
|
||||
export function FileDetailsPanel({
|
||||
@@ -49,11 +53,12 @@ export function FileDetailsPanel({
|
||||
currentFolder,
|
||||
onClose,
|
||||
onAddToWorkspace,
|
||||
onQuickView,
|
||||
onMove,
|
||||
onRemove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
compactVersions = false,
|
||||
onOpenVersionHistory,
|
||||
}: FileDetailsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
@@ -68,6 +73,9 @@ export function FileDetailsPanel({
|
||||
// Hooks must run before any early return.
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
// Metadata (size/type/dates) is collapsed by default so the panel stays
|
||||
// short and the action buttons keep their pinned footer in view.
|
||||
const [fieldsOpen, setFieldsOpen] = useState(false);
|
||||
// Version chain for the selected file; empty for v1 or multi-select.
|
||||
const [versionChain, setVersionChain] = useState<StirlingFileStub[]>([]);
|
||||
const singleFileForChain = files.length === 1 ? files[0] : null;
|
||||
@@ -144,7 +152,11 @@ export function FileDetailsPanel({
|
||||
<div className="files-page-details-body">
|
||||
{single ? (
|
||||
<>
|
||||
<div className="files-page-details-thumb">
|
||||
<div
|
||||
className={`files-page-details-thumb${
|
||||
compactVersions ? " is-compact" : ""
|
||||
}`}
|
||||
>
|
||||
{single.thumbnailUrl ? (
|
||||
<img src={single.thumbnailUrl} alt="" />
|
||||
) : (
|
||||
@@ -174,36 +186,52 @@ export function FileDetailsPanel({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="files-page-details-fieldlist">
|
||||
<DetailField
|
||||
label={t("filesPage.field.size", "Size")}
|
||||
value={formatFileSize(single.size)}
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setFieldsOpen((o) => !o)}
|
||||
aria-expanded={fieldsOpen}
|
||||
>
|
||||
<span>{t("filesPage.fileInfo", "File info")}</span>
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
fieldsOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.type", "Type")}
|
||||
value={single.type || "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.modified", "Modified")}
|
||||
value={getFileDate({ lastModified: single.lastModified })}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.added", "Added")}
|
||||
value={
|
||||
single.createdAt
|
||||
? getFileDate({ lastModified: single.createdAt })
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.folder", "Folder")}
|
||||
value={
|
||||
currentFolder
|
||||
? currentFolder.name
|
||||
: t("filesPage.allFiles", "All files")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{fieldsOpen && (
|
||||
<div className="files-page-details-fieldlist">
|
||||
<DetailField
|
||||
label={t("filesPage.field.size", "Size")}
|
||||
value={formatFileSize(single.size)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.type", "Type")}
|
||||
value={single.type || "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.modified", "Modified")}
|
||||
value={getFileDate({ lastModified: single.lastModified })}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.added", "Added")}
|
||||
value={
|
||||
single.createdAt
|
||||
? getFileDate({ lastModified: single.createdAt })
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.folder", "Folder")}
|
||||
value={
|
||||
currentFolder
|
||||
? currentFolder.name
|
||||
: t("filesPage.allFiles", "All files")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{single.toolHistory && single.toolHistory.length > 0 && (
|
||||
<div className="files-page-details-tool-history">
|
||||
<div className="files-page-details-tool-history-label">
|
||||
@@ -224,15 +252,27 @@ export function FileDetailsPanel({
|
||||
shows WHICH tool was added at each step (the delta from
|
||||
the prior version) so the user can read the journey
|
||||
top-to-bottom. Long chains (> 6) collapse the middle. */}
|
||||
{versionChain.length > 1 && (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onQuickView={onQuickView}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
{versionChain.length > 1 &&
|
||||
(compactVersions && onOpenVersionHistory ? (
|
||||
<Button
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={onOpenVersionHistory}
|
||||
>
|
||||
{t(
|
||||
"filesPage.viewVersionHistory",
|
||||
"Version journey ({{count}})",
|
||||
{ count: versionChain.length },
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="files-page-details-fieldlist">
|
||||
@@ -246,120 +286,107 @@ export function FileDetailsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}
|
||||
<div className="files-page-details-actions">
|
||||
<Button
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
variant="filled"
|
||||
onClick={() => onAddToWorkspace(selectedFileIds)}
|
||||
>
|
||||
<Button
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
variant="filled"
|
||||
onClick={() => onAddToWorkspace(selectedFileIds)}
|
||||
>
|
||||
{files.length === 1
|
||||
? t("filesPage.addToWorkspace", "Add to workspace")
|
||||
: t(
|
||||
"filesPage.addToWorkspaceCount",
|
||||
"Add {{count}} to workspace",
|
||||
{ count: files.length },
|
||||
)}
|
||||
</Button>
|
||||
{single && (
|
||||
<Button
|
||||
leftSection={<VisibilityIcon fontSize="small" />}
|
||||
variant="subtle"
|
||||
onClick={() => onQuickView(single.id)}
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={handleDownload}
|
||||
loading={downloading}
|
||||
>
|
||||
{single
|
||||
? t("filesPage.download", "Download")
|
||||
: t("filesPage.downloadAll", "Download all")}
|
||||
</Button>
|
||||
{/* Share is single-file only. When sharing is disabled in
|
||||
{files.length === 1
|
||||
? t("filesPage.addToWorkspace", "Add to workspace")
|
||||
: t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={handleDownload}
|
||||
loading={downloading}
|
||||
>
|
||||
{single
|
||||
? t("filesPage.download", "Download")
|
||||
: t("filesPage.downloadAll", "Download all")}
|
||||
</Button>
|
||||
{/* Share is single-file only. When sharing is disabled in
|
||||
server config (storage.sharing.enabled=false) we still
|
||||
render the button - disabled with an explanatory tooltip -
|
||||
so users discover the feature exists and know how to
|
||||
enable it, rather than wondering why "share" is missing
|
||||
from the action stack on their build. */}
|
||||
{single && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"filesPage.shareDisabledHint",
|
||||
"File sharing isn't enabled on this server. Ask your admin to enable it.",
|
||||
)}
|
||||
disabled={sharingEnabled}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
<Button
|
||||
leftSection={<LinkIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={!sharingEnabled}
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: sharingEnabled ? undefined : "auto",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.shareManage", "Manage sharing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={() => onMove(selectedFileIds)}
|
||||
{single && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"filesPage.shareDisabledHint",
|
||||
"File sharing isn't enabled on this server. Ask your admin to enable it.",
|
||||
)}
|
||||
disabled={sharingEnabled}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Button>
|
||||
{/* Save to server; shown when any selected file is local-only. When
|
||||
<Button
|
||||
leftSection={<LinkIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={!sharingEnabled}
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: sharingEnabled ? undefined : "auto",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.shareManage", "Manage sharing")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={() => onMove(selectedFileIds)}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Button>
|
||||
{/* Save to server; shown when any selected file is local-only. When
|
||||
storage is off it stays visible but disabled with a tooltip (same
|
||||
treatment as Manage sharing above). */}
|
||||
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => onRemove(selectedFileIds)}
|
||||
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => onRemove(selectedFileIds)}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Single panel-level mount; gated on sharingEnabled. */}
|
||||
{single && sharingEnabled && (
|
||||
@@ -372,309 +399,3 @@ export function FileDetailsPanel({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="files-page-details-field">
|
||||
<span className="files-page-details-field-label">{label}</span>
|
||||
<span className="files-page-details-field-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tool that produced `version` from `prior`; null for v1. */
|
||||
function deltaToolFor(
|
||||
version: StirlingFileStub,
|
||||
prior: StirlingFileStub | null,
|
||||
): ToolOperation | null {
|
||||
if (!prior) return null;
|
||||
const priorLen = prior.toolHistory?.length ?? 0;
|
||||
const curr = version.toolHistory ?? [];
|
||||
return curr[priorLen] ?? null;
|
||||
}
|
||||
|
||||
interface VersionTimelineProps {
|
||||
/** Chain sorted oldest-first. */
|
||||
chain: StirlingFileStub[];
|
||||
/** Currently selected version. */
|
||||
currentId: FileId;
|
||||
onQuickView: (fileId: FileId) => void;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
}
|
||||
|
||||
/** Version timeline with per-row tool deltas and collapse-when-long. */
|
||||
function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onQuickView,
|
||||
onAddToWorkspace,
|
||||
onRemove,
|
||||
}: VersionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
|
||||
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
|
||||
|
||||
// Newest-first ordering.
|
||||
const ordered = useMemo(
|
||||
() =>
|
||||
[...chain].sort(
|
||||
(a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1),
|
||||
),
|
||||
[chain],
|
||||
);
|
||||
|
||||
// Index by versionNumber for prior-version lookup.
|
||||
const byVersionNumber = useMemo(() => {
|
||||
const map = new Map<number, StirlingFileStub>();
|
||||
for (const v of chain) {
|
||||
map.set(v.versionNumber ?? 1, v);
|
||||
}
|
||||
return map;
|
||||
}, [chain]);
|
||||
|
||||
// Collapse middle when long: 3 newest + ellipsis + 2 oldest.
|
||||
const COLLAPSE_THRESHOLD = 6;
|
||||
const collapsible = ordered.length > COLLAPSE_THRESHOLD;
|
||||
type Row =
|
||||
| { kind: "version"; version: StirlingFileStub }
|
||||
| {
|
||||
kind: "ellipsis";
|
||||
hidden: number;
|
||||
};
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!collapsible || showAllCollapsed) {
|
||||
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
}
|
||||
const head = ordered
|
||||
.slice(0, 3)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const tail = ordered
|
||||
.slice(-2)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const hidden = ordered.length - 5;
|
||||
return [...head, { kind: "ellipsis", hidden }, ...tail];
|
||||
}, [collapsible, showAllCollapsed, ordered]);
|
||||
|
||||
const toggleExpand = (id: FileId) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="files-page-details-version-timeline">
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="files-page-details-version-timeline-list">
|
||||
{rows.map((row, idx) => {
|
||||
const isLast = idx === rows.length - 1;
|
||||
if (row.kind === "ellipsis") {
|
||||
return (
|
||||
<li
|
||||
key="ellipsis"
|
||||
className="files-page-details-version-timeline-ellipsis"
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-ellipsis-btn"
|
||||
onClick={() => setShowAllCollapsed(true)}
|
||||
>
|
||||
{t(
|
||||
"filesPage.versionShowHidden",
|
||||
"Show {{count}} earlier versions",
|
||||
{ count: row.hidden },
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const v = row.version;
|
||||
const isActive = v.id === currentId;
|
||||
const isExpanded = expandedIds.has(v.id);
|
||||
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
|
||||
const delta = deltaToolFor(v, prior);
|
||||
return (
|
||||
<li
|
||||
key={v.id}
|
||||
className={`files-page-details-version-timeline-row${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span
|
||||
className={`files-page-details-version-timeline-rail-dot${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<div className="files-page-details-version-timeline-body">
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-summary"
|
||||
onClick={() => toggleExpand(v.id)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={isActive ? "filled" : "outline"}
|
||||
color="blue"
|
||||
>
|
||||
v{v.versionNumber ?? 1}
|
||||
</Badge>
|
||||
{delta ? (
|
||||
<span className="files-page-details-version-timeline-delta">
|
||||
<span className="files-page-details-version-timeline-delta-plus">
|
||||
+
|
||||
</span>
|
||||
<ToolLabel toolId={delta.toolId} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="files-page-details-version-timeline-delta is-origin">
|
||||
{t("filesPage.versionOrigin", "Original upload")}
|
||||
</span>
|
||||
)}
|
||||
<span className="files-page-details-version-timeline-spacer" />
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-version-timeline-chevron${
|
||||
isExpanded ? " is-expanded" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
<div className="files-page-details-version-timeline-meta-line">
|
||||
<span>{formatFileSize(v.size)}</span>
|
||||
{v.lastModified ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{getFileDate({ lastModified: v.lastModified })}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{!isActive && (
|
||||
<>
|
||||
<span className="files-page-details-version-timeline-spacer" />
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"filesPage.versionActions",
|
||||
"Version actions",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<VisibilityIcon fontSize="small" />}
|
||||
onClick={() => onQuickView(v.id)}
|
||||
>
|
||||
{t("filesPage.viewVersion", "View this version")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={() => onAddToWorkspace([v.id])}
|
||||
>
|
||||
{t(
|
||||
"filesPage.openVersionInWorkspace",
|
||||
"Open in workspace",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
void downloadFileFromStorage(v);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"filesPage.downloadVersion",
|
||||
"Download this version",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => onRemove([v.id])}
|
||||
>
|
||||
{t(
|
||||
"filesPage.removeVersion",
|
||||
"Remove this version",
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
// Filename + full cumulative tool chain.
|
||||
<div className="files-page-details-version-timeline-expanded">
|
||||
<DetailField
|
||||
label={t("filesPage.field.name", "Name")}
|
||||
value={v.name}
|
||||
/>
|
||||
{v.toolHistory && v.toolHistory.length > 0 && (
|
||||
<div className="files-page-details-version-timeline-toolchain">
|
||||
<span className="files-page-details-version-timeline-toolchain-label">
|
||||
{t(
|
||||
"filesPage.field.toolHistoryAtVersion",
|
||||
"Cumulative tool chain",
|
||||
)}
|
||||
</span>
|
||||
<ToolChain
|
||||
toolChain={v.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{collapsible && showAllCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-collapse-btn"
|
||||
onClick={() => setShowAllCollapsed(false)}
|
||||
>
|
||||
{t("filesPage.versionCollapse", "Collapse middle versions")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Translated tool name via `home.{toolId}.title`. */
|
||||
function ToolLabel({ toolId }: { toolId: ToolId }) {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
@@ -60,8 +60,6 @@ interface FileGridProps {
|
||||
onOpenFolder: (id: FolderId) => void;
|
||||
/** "Add to workspace". */
|
||||
onOpenFile: (file: StirlingFileStub) => void;
|
||||
/** "Quick view". */
|
||||
onQuickView: (file: StirlingFileStub) => void;
|
||||
onMoveFiles: (
|
||||
fileIds: FileId[],
|
||||
targetFolderId: FolderId | null,
|
||||
@@ -80,6 +78,8 @@ interface FileGridProps {
|
||||
onPromptMoveFiles: (fileIds: FileId[]) => void;
|
||||
/** Per-file Save to server; hidden when file already has remoteStorageId. */
|
||||
onSaveToServer?: (file: StirlingFileStub) => void;
|
||||
/** Open the version-history modal for a file (only when it has >1 version). */
|
||||
onVersionHistory?: (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. */
|
||||
@@ -329,7 +329,6 @@ function GridView({
|
||||
onSelectFile,
|
||||
onOpenFolder,
|
||||
onOpenFile,
|
||||
onQuickView,
|
||||
onMoveFiles,
|
||||
onMoveFolder,
|
||||
onRenameFolder,
|
||||
@@ -338,6 +337,7 @@ function GridView({
|
||||
onRemoveFiles,
|
||||
onPromptMoveFiles,
|
||||
onSaveToServer,
|
||||
onVersionHistory,
|
||||
saveToServerDisabledReason,
|
||||
}: FileGridProps) {
|
||||
return (
|
||||
@@ -380,7 +380,6 @@ function GridView({
|
||||
onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey)
|
||||
}
|
||||
onDoubleClick={() => onOpenFile(entry.file!)}
|
||||
onQuickView={() => onQuickView(entry.file!)}
|
||||
onRemove={() => onRemoveFiles([entry.file!.id])}
|
||||
onMove={() => {
|
||||
const target = selectedFileIds.has(entry.file!.id)
|
||||
@@ -391,6 +390,11 @@ function GridView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
onVersionHistory={
|
||||
onVersionHistory
|
||||
? () => onVersionHistory(entry.file!)
|
||||
: undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
@@ -610,11 +614,12 @@ interface FileCardProps {
|
||||
multiSelectActive: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDoubleClick: () => void;
|
||||
onQuickView: () => 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;
|
||||
}
|
||||
@@ -628,10 +633,10 @@ function FileCard({
|
||||
multiSelectActive,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onQuickView,
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
onVersionHistory,
|
||||
saveToServerDisabledReason,
|
||||
}: FileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -786,15 +791,6 @@ function FileCard({
|
||||
>
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<VisibilityIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickView();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
@@ -834,6 +830,17 @@ function FileCard({
|
||||
</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"
|
||||
@@ -860,12 +867,12 @@ function ListView({
|
||||
onSetSelection,
|
||||
onOpenFolder,
|
||||
onOpenFile,
|
||||
onQuickView,
|
||||
onMoveFiles,
|
||||
onMoveFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onSaveToServer,
|
||||
onVersionHistory,
|
||||
saveToServerDisabledReason,
|
||||
onChangeFolderAppearance,
|
||||
onRemoveFiles,
|
||||
@@ -989,7 +996,6 @@ function ListView({
|
||||
onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey)
|
||||
}
|
||||
onOpen={() => onOpenFile(entry.file!)}
|
||||
onQuickView={() => onQuickView(entry.file!)}
|
||||
onRemove={() => onRemoveFiles([entry.file!.id])}
|
||||
onMove={() => {
|
||||
const target = selectedFileIds.has(entry.file!.id)
|
||||
@@ -1000,6 +1006,11 @@ function ListView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
onVersionHistory={
|
||||
onVersionHistory
|
||||
? () => onVersionHistory(entry.file!)
|
||||
: undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
@@ -1198,11 +1209,12 @@ interface FileRowProps {
|
||||
multiSelectActive: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
onQuickView: () => 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;
|
||||
}
|
||||
@@ -1216,10 +1228,10 @@ function FileRow({
|
||||
multiSelectActive,
|
||||
onClick,
|
||||
onOpen,
|
||||
onQuickView,
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
onVersionHistory,
|
||||
saveToServerDisabledReason,
|
||||
}: FileRowProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1377,15 +1389,6 @@ function FileRow({
|
||||
>
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<VisibilityIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickView();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
@@ -1424,6 +1427,17 @@ function FileRow({
|
||||
</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"
|
||||
|
||||
@@ -28,7 +28,6 @@ import ViewListIcon from "@mui/icons-material/ViewList";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
@@ -67,15 +66,14 @@ import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
||||
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 { materializeServerStubs } from "@app/services/fileSyncService";
|
||||
import {
|
||||
FILES_PAGE_DRAG_TYPE,
|
||||
parseFilesPageDragPayload,
|
||||
} from "@app/components/filesPage/dragDrop";
|
||||
import {
|
||||
clearFilesPageReturnRoute,
|
||||
setFilesPageReturnRoute,
|
||||
} from "@app/components/filesPage/filesPageReturnRoute";
|
||||
import { clearFilesPageReturnRoute } from "@app/components/filesPage/filesPageReturnRoute";
|
||||
import "@app/components/filesPage/FilesPage.css";
|
||||
|
||||
export default function FileManagerView() {
|
||||
@@ -96,6 +94,9 @@ export default function FileManagerView() {
|
||||
const [saveToServerTarget, setSaveToServerTarget] = useState<
|
||||
StirlingFileStub[] | null
|
||||
>(null);
|
||||
// Version-history modal target (opened from the card kebab).
|
||||
const [versionHistoryFile, setVersionHistoryFile] =
|
||||
useState<StirlingFileStub | null>(null);
|
||||
const folders = useFolders();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { fileIds: activeWorkspaceFileIds } = useAllFiles();
|
||||
@@ -166,6 +167,10 @@ export default function FileManagerView() {
|
||||
moveFilesTo,
|
||||
moveFolderTo,
|
||||
removeFiles,
|
||||
deleteDialogFileIds,
|
||||
deleteDialogOpen,
|
||||
closeDeleteDialog,
|
||||
confirmRemoveFiles,
|
||||
promptDeleteFolder,
|
||||
deleteFolder,
|
||||
deleteFolderDialog,
|
||||
@@ -173,6 +178,15 @@ export default function FileManagerView() {
|
||||
setFolderAppearance,
|
||||
} = filesPage;
|
||||
|
||||
// Resolve queued delete ids into stubs for the DeleteFilesDialog.
|
||||
const deleteDialogFiles = useMemo(
|
||||
() =>
|
||||
deleteDialogFileIds
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s)),
|
||||
[deleteDialogFileIds, fileMap],
|
||||
);
|
||||
|
||||
const setCurrentFolderId = folders.setCurrentFolderId;
|
||||
const foldersById = folders.foldersById;
|
||||
const currentFolderId = folders.currentFolderId;
|
||||
@@ -538,30 +552,16 @@ export default function FileManagerView() {
|
||||
[handleNativeUpload],
|
||||
);
|
||||
|
||||
// ─── add to workspace vs quick view ─────────────────────────────────────
|
||||
// addToWorkspace: commit; no back affordance.
|
||||
// quickView: peek; "Back to My Files" pill in WorkbenchBar.
|
||||
// ─── add to workspace ───────────────────────────────────────────────────
|
||||
const openFilesInWorkbench = useCallback(
|
||||
async (fileIds: FileId[], options: { trackReturn: boolean }) => {
|
||||
async (fileIds: FileId[]) => {
|
||||
const stubs = fileIds
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
||||
if (stubs.length === 0) return;
|
||||
|
||||
const proceed = async () => {
|
||||
if (options.trackReturn) {
|
||||
const returnRoute =
|
||||
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
|
||||
const folderRecord = currentFolderId
|
||||
? (foldersById.get(currentFolderId) ?? null)
|
||||
: null;
|
||||
const returnLabel = folderRecord
|
||||
? folderRecord.name
|
||||
: t("filesPage.myFiles", "My Files");
|
||||
setFilesPageReturnRoute(returnRoute, returnLabel);
|
||||
} else {
|
||||
clearFilesPageReturnRoute();
|
||||
}
|
||||
clearFilesPageReturnRoute();
|
||||
|
||||
// Server-only stubs have no bytes in IDB; download + ingest first.
|
||||
const materialized = await materializeServerStubs(stubs, {
|
||||
@@ -599,20 +599,12 @@ export default function FileManagerView() {
|
||||
navActions,
|
||||
navigate,
|
||||
requestNavigation,
|
||||
currentFolderId,
|
||||
foldersById,
|
||||
t,
|
||||
clearFilesPageReturnRoute,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddToWorkspace = useCallback(
|
||||
(fileIds: FileId[]) =>
|
||||
openFilesInWorkbench(fileIds, { trackReturn: false }),
|
||||
[openFilesInWorkbench],
|
||||
);
|
||||
|
||||
const handleQuickView = useCallback(
|
||||
(fileId: FileId) => openFilesInWorkbench([fileId], { trackReturn: true }),
|
||||
(fileIds: FileId[]) => openFilesInWorkbench(fileIds),
|
||||
[openFilesInWorkbench],
|
||||
);
|
||||
|
||||
@@ -1189,7 +1181,6 @@ export default function FileManagerView() {
|
||||
);
|
||||
const moveLabel = t("filesPage.moveTo", "Move to…");
|
||||
const removeLabel = t("filesPage.remove", "Remove");
|
||||
const quickViewLabel = t("filesPage.quickView", "Quick view");
|
||||
return (
|
||||
// wrap="nowrap" keeps the row single-line.
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
@@ -1204,19 +1195,6 @@ export default function FileManagerView() {
|
||||
{addLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{selectedFiles.length === 1 && (
|
||||
<Tooltip label={quickViewLabel} withinPortal>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
leftSection={<VisibilityIcon fontSize="small" />}
|
||||
onClick={() => handleQuickView(selectedFiles[0]!)}
|
||||
aria-label={quickViewLabel}
|
||||
>
|
||||
{quickViewLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Save to server; shown whenever local-only files are
|
||||
selected. When storage is off it stays visible but
|
||||
disabled, tooltip pointing at the admin. */}
|
||||
@@ -1475,7 +1453,6 @@ export default function FileManagerView() {
|
||||
onSetSelection={setSelectedFileIds}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onOpenFile={handleOpenFile}
|
||||
onQuickView={(file) => handleQuickView(file.id)}
|
||||
onMoveFiles={moveFilesTo}
|
||||
onMoveFolder={moveFolderTo}
|
||||
onRenameFolder={openRenameFolderDialog}
|
||||
@@ -1498,6 +1475,7 @@ export default function FileManagerView() {
|
||||
onRemoveFiles={handleRemoveFiles}
|
||||
onPromptMoveFiles={promptMoveFiles}
|
||||
onSaveToServer={(file) => setSaveToServerTarget([file])}
|
||||
onVersionHistory={(file) => setVersionHistoryFile(file)}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
// Center-of-grid CTAs when the empty state shows - same
|
||||
// handlers the corner header buttons use so behaviour
|
||||
@@ -1540,7 +1518,6 @@ export default function FileManagerView() {
|
||||
currentFolder={currentFolderRecord}
|
||||
onClose={() => clearSelection()}
|
||||
onAddToWorkspace={handleAddToWorkspace}
|
||||
onQuickView={handleQuickView}
|
||||
onMove={promptMoveFiles}
|
||||
onRemove={handleRemoveFiles}
|
||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||
@@ -1567,11 +1544,18 @@ export default function FileManagerView() {
|
||||
currentFolder={currentFolderRecord}
|
||||
onClose={() => setMobileDetailsOpen(false)}
|
||||
onAddToWorkspace={handleAddToWorkspace}
|
||||
onQuickView={handleQuickView}
|
||||
onMove={promptMoveFiles}
|
||||
onRemove={handleRemoveFiles}
|
||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
compactVersions
|
||||
onOpenVersionHistory={() => {
|
||||
const f = fileMap.get(selectedFiles[0]);
|
||||
if (f) {
|
||||
setMobileDetailsOpen(false);
|
||||
setVersionHistoryFile(f);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
@@ -1643,6 +1627,22 @@ export default function FileManagerView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cloud-aware delete; offers local/cloud/both when a file lives in both. */}
|
||||
<DeleteFilesDialog
|
||||
opened={deleteDialogOpen}
|
||||
files={deleteDialogFiles}
|
||||
onClose={closeDeleteDialog}
|
||||
onConfirm={confirmRemoveFiles}
|
||||
/>
|
||||
|
||||
{/* Version journey in a modal (opened from the card kebab). */}
|
||||
<VersionHistoryModal
|
||||
opened={Boolean(versionHistoryFile)}
|
||||
onClose={() => setVersionHistoryFile(null)}
|
||||
file={versionHistoryFile}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
|
||||
{/* Save-to-server modal; keyed on target so updates don't retarget. */}
|
||||
<BulkUploadToServerModal
|
||||
key={`save-${(saveToServerTarget ?? []).map((s) => s.id).join(",")}`}
|
||||
|
||||
@@ -847,14 +847,55 @@
|
||||
and bottom padding stays at 1.1rem so the content still breathes
|
||||
against the panel edges. */
|
||||
padding: 0.3rem 1.1rem 1.1rem;
|
||||
/* Grow to fill the panel and scroll internally so the action footer below
|
||||
stays pinned and visible regardless of how tall the content gets. */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
/* Pinned action footer - always visible so the buttons never scroll off. */
|
||||
.files-page-details-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
}
|
||||
|
||||
/* Collapsible "File info" header (size/type/dates). */
|
||||
.files-page-details-collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, var(--text-primary));
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.files-page-details-collapse-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.files-page-details-collapse-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.files-page-details-thumb {
|
||||
aspect-ratio: 4 / 3;
|
||||
/* Compact fixed height (was a tall 4:3 box that dominated the panel and
|
||||
pushed the action buttons off the bottom). */
|
||||
height: 6.5rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.6rem;
|
||||
@@ -877,6 +918,11 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* On small screens (drawer) the preview isn't worth the vertical space. */
|
||||
.files-page-details-thumb.is-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.files-page-details-fieldlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Center, Loader, Modal, Text } from "@mantine/core";
|
||||
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useIndexedDBRevision } from "@app/contexts/IndexedDBContext";
|
||||
import { VersionTimeline } from "@app/components/filesPage/VersionTimeline";
|
||||
|
||||
interface VersionHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** The file whose version journey to show (the current/leaf version). */
|
||||
file: StirlingFileStub | null;
|
||||
/** Called after a destructive change so the launcher can refresh its list. */
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained modal that renders the same Version Journey timeline used in
|
||||
* the details panel. Loads the chain itself and wires view/open/remove to the
|
||||
* file context, so it can be opened from anywhere (sidebar or /files card).
|
||||
*/
|
||||
export function VersionHistoryModal({
|
||||
opened,
|
||||
onClose,
|
||||
file,
|
||||
onChanged,
|
||||
}: VersionHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const dbRevision = useIndexedDBRevision();
|
||||
|
||||
const [chain, setChain] = useState<StirlingFileStub[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !file) {
|
||||
setChain([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const rootId = (file.originalFileId ?? file.id) as FileId;
|
||||
fileStorage
|
||||
.getHistoryChainStubs(rootId)
|
||||
.then((c) => {
|
||||
if (!cancelled) setChain(c);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load version history", err);
|
||||
if (!cancelled) setChain([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// dbRevision so the chain refreshes after a version is removed.
|
||||
}, [opened, file, dbRevision]);
|
||||
|
||||
const stubById = useCallback(
|
||||
(id: FileId) => chain.find((c) => c.id === id),
|
||||
[chain],
|
||||
);
|
||||
|
||||
const handleAddToWorkspace = useCallback(
|
||||
(ids: FileId[]) => {
|
||||
const stubs = ids
|
||||
.map((id) => stubById(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
||||
if (stubs.length === 0) return;
|
||||
void fileActions.addStirlingFileStubs(stubs);
|
||||
navActions.setWorkbench("fileEditor");
|
||||
onClose();
|
||||
},
|
||||
[stubById, fileActions, navActions, onClose],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (ids: FileId[]) => {
|
||||
await fileActions.removeFiles(ids, true);
|
||||
onChanged?.();
|
||||
// If only one version remains there's no journey to show.
|
||||
const remaining = chain.filter((c) => !ids.includes(c.id));
|
||||
if (remaining.length <= 1) onClose();
|
||||
},
|
||||
[fileActions, chain, onChanged, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="md"
|
||||
title={t("filesPage.field.versionHistory", "Version journey")}
|
||||
>
|
||||
{loading ? (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : chain.length > 1 && file ? (
|
||||
<VersionTimeline
|
||||
chain={chain}
|
||||
currentId={file.id}
|
||||
onAddToWorkspace={handleAddToWorkspace}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm">
|
||||
{t(
|
||||
"filesPage.versionHistoryEmpty",
|
||||
"This file has no earlier versions.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Badge, Menu } from "@mantine/core";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
|
||||
import { FileId, ToolOperation } from "@app/types/file";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
|
||||
/** Small label/value row; shared with FileDetailsPanel. */
|
||||
export function DetailField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="files-page-details-field">
|
||||
<span className="files-page-details-field-label">{label}</span>
|
||||
<span className="files-page-details-field-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tool that produced `version` from `prior`; null for v1. */
|
||||
function deltaToolFor(
|
||||
version: StirlingFileStub,
|
||||
prior: StirlingFileStub | null,
|
||||
): ToolOperation | null {
|
||||
if (!prior) return null;
|
||||
const priorLen = prior.toolHistory?.length ?? 0;
|
||||
const curr = version.toolHistory ?? [];
|
||||
return curr[priorLen] ?? null;
|
||||
}
|
||||
|
||||
/** Translated tool name via `home.{toolId}.title`. */
|
||||
function ToolLabel({ toolId }: { toolId: ToolId }) {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
|
||||
}
|
||||
|
||||
export interface VersionTimelineProps {
|
||||
/** Chain sorted oldest-first. */
|
||||
chain: StirlingFileStub[];
|
||||
/** Currently selected version. */
|
||||
currentId: FileId;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
}
|
||||
|
||||
/** Version timeline with per-row tool deltas and collapse-when-long. */
|
||||
export function VersionTimeline({
|
||||
chain,
|
||||
currentId,
|
||||
onAddToWorkspace,
|
||||
onRemove,
|
||||
}: VersionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
|
||||
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
|
||||
|
||||
// Newest-first ordering.
|
||||
const ordered = useMemo(
|
||||
() =>
|
||||
[...chain].sort(
|
||||
(a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1),
|
||||
),
|
||||
[chain],
|
||||
);
|
||||
|
||||
// Index by versionNumber for prior-version lookup.
|
||||
const byVersionNumber = useMemo(() => {
|
||||
const map = new Map<number, StirlingFileStub>();
|
||||
for (const v of chain) {
|
||||
map.set(v.versionNumber ?? 1, v);
|
||||
}
|
||||
return map;
|
||||
}, [chain]);
|
||||
|
||||
// Collapse middle when long: 3 newest + ellipsis + 2 oldest.
|
||||
const COLLAPSE_THRESHOLD = 6;
|
||||
const collapsible = ordered.length > COLLAPSE_THRESHOLD;
|
||||
type Row =
|
||||
| { kind: "version"; version: StirlingFileStub }
|
||||
| {
|
||||
kind: "ellipsis";
|
||||
hidden: number;
|
||||
};
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!collapsible || showAllCollapsed) {
|
||||
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
}
|
||||
const head = ordered
|
||||
.slice(0, 3)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const tail = ordered
|
||||
.slice(-2)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
const hidden = ordered.length - 5;
|
||||
return [...head, { kind: "ellipsis", hidden }, ...tail];
|
||||
}, [collapsible, showAllCollapsed, ordered]);
|
||||
|
||||
const toggleExpand = (id: FileId) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="files-page-details-version-timeline">
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="files-page-details-version-timeline-list">
|
||||
{rows.map((row, idx) => {
|
||||
const isLast = idx === rows.length - 1;
|
||||
if (row.kind === "ellipsis") {
|
||||
return (
|
||||
<li
|
||||
key="ellipsis"
|
||||
className="files-page-details-version-timeline-ellipsis"
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-ellipsis-btn"
|
||||
onClick={() => setShowAllCollapsed(true)}
|
||||
>
|
||||
{t(
|
||||
"filesPage.versionShowHidden",
|
||||
"Show {{count}} earlier versions",
|
||||
{ count: row.hidden },
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const v = row.version;
|
||||
const isActive = v.id === currentId;
|
||||
const isExpanded = expandedIds.has(v.id);
|
||||
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
|
||||
const delta = deltaToolFor(v, prior);
|
||||
return (
|
||||
<li
|
||||
key={v.id}
|
||||
className={`files-page-details-version-timeline-row${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="files-page-details-version-timeline-rail">
|
||||
<span
|
||||
className={`files-page-details-version-timeline-rail-dot${
|
||||
isActive ? " is-active" : ""
|
||||
}`}
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="files-page-details-version-timeline-rail-line" />
|
||||
)}
|
||||
</div>
|
||||
<div className="files-page-details-version-timeline-body">
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-summary"
|
||||
onClick={() => toggleExpand(v.id)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={isActive ? "filled" : "outline"}
|
||||
color="blue"
|
||||
>
|
||||
v{v.versionNumber ?? 1}
|
||||
</Badge>
|
||||
{delta ? (
|
||||
<span className="files-page-details-version-timeline-delta">
|
||||
<span className="files-page-details-version-timeline-delta-plus">
|
||||
+
|
||||
</span>
|
||||
<ToolLabel toolId={delta.toolId} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="files-page-details-version-timeline-delta is-origin">
|
||||
{t("filesPage.versionOrigin", "Original upload")}
|
||||
</span>
|
||||
)}
|
||||
<span className="files-page-details-version-timeline-spacer" />
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-version-timeline-chevron${
|
||||
isExpanded ? " is-expanded" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
<div className="files-page-details-version-timeline-meta-line">
|
||||
<span>{formatFileSize(v.size)}</span>
|
||||
{v.lastModified ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{getFileDate({ lastModified: v.lastModified })}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{/* Kebab on every row - the original/active version also
|
||||
needs download + open-in-workspace. */}
|
||||
<span className="files-page-details-version-timeline-spacer" />
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"filesPage.versionActions",
|
||||
"Version actions",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={() => onAddToWorkspace([v.id])}
|
||||
>
|
||||
{t(
|
||||
"filesPage.openVersionInWorkspace",
|
||||
"Open in workspace",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
void downloadFileFromStorage(v);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"filesPage.downloadVersion",
|
||||
"Download this version",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={() => onRemove([v.id])}
|
||||
>
|
||||
{t("filesPage.removeVersion", "Remove this version")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
// Filename + full cumulative tool chain.
|
||||
<div className="files-page-details-version-timeline-expanded">
|
||||
<DetailField
|
||||
label={t("filesPage.field.name", "Name")}
|
||||
value={v.name}
|
||||
/>
|
||||
{v.toolHistory && v.toolHistory.length > 0 && (
|
||||
<div className="files-page-details-version-timeline-toolchain">
|
||||
<span className="files-page-details-version-timeline-toolchain-label">
|
||||
{t(
|
||||
"filesPage.field.toolHistoryAtVersion",
|
||||
"Cumulative tool chain",
|
||||
)}
|
||||
</span>
|
||||
<ToolChain
|
||||
toolChain={v.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{collapsible && showAllCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-version-timeline-collapse-btn"
|
||||
onClick={() => setShowAllCollapsed(false)}
|
||||
>
|
||||
{t("filesPage.versionCollapse", "Collapse middle versions")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,15 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
|
||||
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
|
||||
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
|
||||
import {
|
||||
deleteServerFile,
|
||||
type DeleteScope,
|
||||
} from "@app/services/serverStorageDelete";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
@@ -182,7 +191,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// Each auth layer derives its own displayName from its native user shape.
|
||||
// Fall back to the proprietary REST endpoint only when the auth
|
||||
// context yields nothing - then to "User" as a generic last resort.
|
||||
const { displayName: authDisplayName } = useAuth();
|
||||
const { displayName: authDisplayName, isAnonymous } = useAuth();
|
||||
const [accountUsername, setAccountUsername] = useState<string | null>(null);
|
||||
const displayName =
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
|
||||
@@ -220,6 +229,21 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [stubsLoaded, setStubsLoaded] = useState(false);
|
||||
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
|
||||
const [saveToServerTarget, setSaveToServerTarget] = useState<
|
||||
StirlingFileStub[] | null
|
||||
>(null);
|
||||
// Kebab "Version history" target; drives VersionHistoryModal.
|
||||
const [versionHistoryTarget, setVersionHistoryTarget] =
|
||||
useState<StirlingFileStub | null>(null);
|
||||
// Kebab "Delete" target when the file is on the cloud; drives the
|
||||
// local/cloud/both choice dialog. Local-only files delete immediately.
|
||||
const [deleteTarget, setDeleteTarget] = 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;
|
||||
|
||||
const refreshStubs = useCallback(async () => {
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
@@ -247,6 +271,76 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
refreshStubs();
|
||||
}, [refreshStubs, indexedDBRevision]);
|
||||
|
||||
// Kebab delete: local-only files go immediately (cheap, re-addable). When
|
||||
// the file is also on the cloud, open the choice dialog so the user picks
|
||||
// where to remove it from.
|
||||
const handleSidebarDelete = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
const hasCloud =
|
||||
!!stub &&
|
||||
typeof stub.remoteStorageId === "number" &&
|
||||
stub.remoteOwnedByCurrentUser === true;
|
||||
if (hasCloud && stub) {
|
||||
setDeleteTarget(stub);
|
||||
return;
|
||||
}
|
||||
await fileActions.removeFiles([fileId], true);
|
||||
await refreshStubs();
|
||||
},
|
||||
[allFileStubs, fileActions, refreshStubs],
|
||||
);
|
||||
|
||||
const handleConfirmSidebarDelete = useCallback(
|
||||
async (scope: DeleteScope) => {
|
||||
const stub = deleteTarget;
|
||||
if (!stub) return;
|
||||
if (
|
||||
(scope === "cloud" || scope === "everywhere") &&
|
||||
typeof stub.remoteStorageId === "number" &&
|
||||
stub.remoteOwnedByCurrentUser === true
|
||||
) {
|
||||
await deleteServerFile(stub.remoteStorageId);
|
||||
}
|
||||
if (scope === "device" || scope === "everywhere") {
|
||||
await fileActions.removeFiles([stub.id], true);
|
||||
} else if (scope === "cloud") {
|
||||
// Local copy kept - drop the dead remote pointer so the cloud badge
|
||||
// clears (the sidebar doesn't reconcile with the server itself).
|
||||
const cleared = {
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
};
|
||||
fileActions.updateStirlingFileStub(stub.id, cleared);
|
||||
await fileStorage.updateFileMetadata(stub.id, cleared);
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
await refreshStubs();
|
||||
},
|
||||
[deleteTarget, fileActions, refreshStubs],
|
||||
);
|
||||
|
||||
// Kebab: open the upload-to-server modal for this one file.
|
||||
const handleSaveToCloud = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (stub) setSaveToServerTarget([stub]);
|
||||
},
|
||||
[allFileStubs],
|
||||
);
|
||||
|
||||
// Kebab: open the version-history modal for this one file.
|
||||
const handleVersionHistory = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (stub) setVersionHistoryTarget(stub);
|
||||
},
|
||||
[allFileStubs],
|
||||
);
|
||||
|
||||
// Once a pending file lands in state, open it in the viewer.
|
||||
useEffect(() => {
|
||||
if (!pendingViewFileId) return;
|
||||
@@ -944,6 +1038,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
: (workbenchFileId
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
// local | cloud | shared-with-me - drives the cloud badge
|
||||
// and the Upload-vs-Update menu label.
|
||||
const fileOrigin = getFileOrigin(stub);
|
||||
return (
|
||||
<FileItem
|
||||
key={stub.id}
|
||||
@@ -964,6 +1061,26 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
policies={
|
||||
policyFileBadges.get(stub.id as string) ?? []
|
||||
}
|
||||
onDelete={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleSidebarDelete
|
||||
}
|
||||
onSaveToCloud={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleSaveToCloud
|
||||
}
|
||||
canSaveToCloud={
|
||||
storageEnabled && fileOrigin !== "shared-with-me"
|
||||
}
|
||||
isUploadedToCloud={fileOrigin === "cloud"}
|
||||
onVersionHistory={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleVersionHistory
|
||||
}
|
||||
hasVersionHistory={(stub.versionNumber ?? 1) > 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -985,6 +1102,30 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kebab "Save to cloud" upload modal (one file at a time). */}
|
||||
<BulkUploadToServerModal
|
||||
opened={Boolean(saveToServerTarget && saveToServerTarget.length > 0)}
|
||||
onClose={() => setSaveToServerTarget(null)}
|
||||
files={saveToServerTarget ?? []}
|
||||
onUploaded={refreshStubs}
|
||||
/>
|
||||
|
||||
{/* Kebab "Version history" modal. */}
|
||||
<VersionHistoryModal
|
||||
opened={Boolean(versionHistoryTarget)}
|
||||
onClose={() => setVersionHistoryTarget(null)}
|
||||
file={versionHistoryTarget}
|
||||
onChanged={refreshStubs}
|
||||
/>
|
||||
|
||||
{/* Cloud-aware delete choice (only opened for cloud-uploaded files). */}
|
||||
<DeleteFilesDialog
|
||||
opened={Boolean(deleteTarget)}
|
||||
files={deleteTarget ? [deleteTarget] : []}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleConfirmSidebarDelete}
|
||||
/>
|
||||
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
|
||||
@@ -179,6 +179,15 @@
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Cloud badge (file saved to the server) ---- */
|
||||
.file-sidebar-cloud-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-interactive, #6366f1);
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
.file-sidebar-folder-tags {
|
||||
display: flex;
|
||||
@@ -312,6 +321,36 @@
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ---- Kebab button (per-file actions menu) ---- */
|
||||
.file-sidebar-kebab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Reveal on row hover, and keep it shown while its menu is open. */
|
||||
.file-sidebar-file-item:hover .file-sidebar-kebab-btn,
|
||||
.file-sidebar-kebab-btn[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-sidebar-kebab-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- Date group headers ---- */
|
||||
.file-sidebar-date-group-header {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
|
||||
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
@@ -157,6 +162,18 @@ export interface FileItemProps {
|
||||
onFolderClick?: (folderId: string) => void;
|
||||
/** Policies that have run on this file — rendered as small shield badges. */
|
||||
policies?: FileItemPolicyRef[];
|
||||
/** Delete (local only) from the kebab menu. Omit to hide the menu's delete. */
|
||||
onDelete?: (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). */
|
||||
canSaveToCloud?: boolean;
|
||||
/** File already lives on the server - shows a cloud badge + "Update" label. */
|
||||
isUploadedToCloud?: boolean;
|
||||
/** Open the version-history modal. Only shown when hasVersionHistory. */
|
||||
onVersionHistory?: (fileId: FileId) => void;
|
||||
/** Whether this file has more than one version (drives the menu item). */
|
||||
hasVersionHistory?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
@@ -178,6 +195,12 @@ export function FileItem({
|
||||
folders = [],
|
||||
onFolderClick,
|
||||
policies = [],
|
||||
onDelete,
|
||||
onSaveToCloud,
|
||||
canSaveToCloud = false,
|
||||
isUploadedToCloud = false,
|
||||
onVersionHistory,
|
||||
hasVersionHistory = false,
|
||||
}: FileItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = getFileExtension(name);
|
||||
@@ -263,6 +286,20 @@ export function FileItem({
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</span>
|
||||
{isUploadedToCloud && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"fileSidebar.fileItem.savedToServer",
|
||||
"Saved to server",
|
||||
)}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span className="file-sidebar-cloud-badge" data-no-select>
|
||||
<CloudDoneIcon sx={{ fontSize: "0.85rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{policies.length > 0 && (
|
||||
<span className="file-sidebar-policy-badges" data-no-select>
|
||||
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
|
||||
@@ -355,6 +392,73 @@ export function FileItem({
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{(onDelete ||
|
||||
onSaveToCloud ||
|
||||
(hasVersionHistory && onVersionHistory)) && (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
|
||||
<Menu.Target>
|
||||
<button
|
||||
className="file-sidebar-kebab-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={t(
|
||||
"fileSidebar.fileItem.moreActions",
|
||||
"More actions",
|
||||
)}
|
||||
>
|
||||
<MoreVertIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
{canSaveToCloud && onSaveToCloud && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToCloud(fileId);
|
||||
}}
|
||||
>
|
||||
{isUploadedToCloud
|
||||
? t(
|
||||
"fileSidebar.fileItem.updateOnServer",
|
||||
"Update on server",
|
||||
)
|
||||
: t(
|
||||
"fileSidebar.fileItem.uploadToServer",
|
||||
"Upload to server",
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteOutlineIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(fileId);
|
||||
}}
|
||||
>
|
||||
{t("fileSidebar.fileItem.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{useRasterThumb &&
|
||||
|
||||
@@ -18,6 +18,10 @@ import { fileStorage } from "@app/services/fileStorage";
|
||||
import { folderSyncService } from "@app/services/folderSyncService";
|
||||
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
||||
import { reconcileServerFiles } from "@app/services/fileSyncService";
|
||||
import {
|
||||
deleteServerFile,
|
||||
type DeleteScope,
|
||||
} from "@app/services/serverStorageDelete";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
@@ -113,7 +117,14 @@ interface FilesPageContextValue {
|
||||
folderId: FolderId,
|
||||
newParentId: FolderId | null,
|
||||
) => Promise<void>;
|
||||
/** Queue files for deletion - opens the DeleteFilesDialog. */
|
||||
removeFiles: (fileIds: FileId[]) => Promise<void>;
|
||||
/** Files currently queued in the delete dialog (empty when closed). */
|
||||
deleteDialogFileIds: FileId[];
|
||||
deleteDialogOpen: boolean;
|
||||
closeDeleteDialog: () => void;
|
||||
/** Confirmed delete; scope picks local, cloud, or both. */
|
||||
confirmRemoveFiles: (scope: DeleteScope) => Promise<void>;
|
||||
/** Open the confirmation dialog; consumer renders DeleteFolderDialog. */
|
||||
promptDeleteFolder: (folder: FolderRecord) => void;
|
||||
/** Confirmed delete; pass deleteContents=true to also remove files inside. */
|
||||
@@ -391,26 +402,110 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
[folders, t],
|
||||
);
|
||||
|
||||
// Delete dialog state. removeFiles only queues + opens when a cloud copy is
|
||||
// involved; local-only deletes skip the dialog and run immediately.
|
||||
const [deleteDialogFileIds, setDeleteDialogFileIds] = useState<FileId[]>([]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// The actual deletion for a chosen scope. Shared by the direct (local-only)
|
||||
// path and the dialog's confirm.
|
||||
const performDelete = useCallback(
|
||||
async (fileIds: FileId[], scope: DeleteScope) => {
|
||||
const stubs = fileIds
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
||||
|
||||
// Cloud delete (owner-only). Dedup by remoteStorageId since a history
|
||||
// chain shares a single server file.
|
||||
if (scope === "cloud" || scope === "everywhere") {
|
||||
const remoteIds = Array.from(
|
||||
new Set(
|
||||
stubs
|
||||
.filter(
|
||||
(s) =>
|
||||
typeof s.remoteStorageId === "number" &&
|
||||
s.remoteOwnedByCurrentUser === true,
|
||||
)
|
||||
.map((s) => s.remoteStorageId as number),
|
||||
),
|
||||
);
|
||||
if (remoteIds.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
remoteIds.map((id) => deleteServerFile(id)),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
folders.setError(
|
||||
t(
|
||||
"filesPage.error.cloudDeleteFailed",
|
||||
"Couldn't delete {{count}} file(s) from the cloud.",
|
||||
{ count: failed },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local delete - skip ephemeral server-/shared- stubs (no IDB row).
|
||||
if (scope === "device" || scope === "everywhere") {
|
||||
const localIds = stubs
|
||||
.filter((s) => {
|
||||
const id = String(s.id);
|
||||
return !id.startsWith("server-") && !id.startsWith("shared-");
|
||||
})
|
||||
.map((s) => s.id);
|
||||
if (localIds.length > 0) {
|
||||
await fileActions.removeFiles(localIds, true);
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = new Set(fileIds);
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of removedIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// reconcile picks up the cloud deletions and strips stale remote pointers.
|
||||
await refresh();
|
||||
},
|
||||
[fileMap, fileActions, folders, refresh, t],
|
||||
);
|
||||
|
||||
const removeFiles = useCallback(
|
||||
async (fileIds: FileId[]) => {
|
||||
if (fileIds.length === 0) return;
|
||||
const ok = window.confirm(
|
||||
t(
|
||||
"filesPage.removeConfirm",
|
||||
"Delete {{count}} file(s)? This cannot be undone.",
|
||||
{ count: fileIds.length },
|
||||
),
|
||||
);
|
||||
if (!ok) return;
|
||||
await fileActions.removeFiles(fileIds, true);
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of fileIds) next.delete(id);
|
||||
return next;
|
||||
// Only prompt when a cloud copy is in play (the user must pick where to
|
||||
// delete). Local-only files have nothing to choose - delete immediately.
|
||||
const hasDeletableCloud = fileIds.some((id) => {
|
||||
const s = fileMap.get(id);
|
||||
return (
|
||||
s != null &&
|
||||
typeof s.remoteStorageId === "number" &&
|
||||
s.remoteOwnedByCurrentUser === true
|
||||
);
|
||||
});
|
||||
await refresh();
|
||||
if (!hasDeletableCloud) {
|
||||
await performDelete(fileIds, "device");
|
||||
return;
|
||||
}
|
||||
setDeleteDialogFileIds(fileIds);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
[fileActions, refresh, t],
|
||||
[fileMap, performDelete],
|
||||
);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteDialogFileIds([]);
|
||||
}, []);
|
||||
|
||||
const confirmRemoveFiles = useCallback(
|
||||
async (scope: DeleteScope) => {
|
||||
await performDelete(deleteDialogFileIds, scope);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteDialogFileIds([]);
|
||||
},
|
||||
[deleteDialogFileIds, performDelete],
|
||||
);
|
||||
|
||||
const setFolderAppearance = useCallback(
|
||||
@@ -510,6 +605,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
moveFilesTo,
|
||||
moveFolderTo,
|
||||
removeFiles,
|
||||
deleteDialogFileIds,
|
||||
deleteDialogOpen,
|
||||
closeDeleteDialog,
|
||||
confirmRemoveFiles,
|
||||
promptDeleteFolder,
|
||||
deleteFolder,
|
||||
deleteFolderDialog,
|
||||
@@ -541,6 +640,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
moveFilesTo,
|
||||
moveFolderTo,
|
||||
removeFiles,
|
||||
deleteDialogFileIds,
|
||||
deleteDialogOpen,
|
||||
closeDeleteDialog,
|
||||
confirmRemoveFiles,
|
||||
promptDeleteFolder,
|
||||
deleteFolder,
|
||||
deleteFolderDialog,
|
||||
|
||||
@@ -486,7 +486,12 @@ export default function HomePage() {
|
||||
<MyFilesAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
active={navigationState.workbench === "myFiles"}
|
||||
collapsed={fileSidebarCollapsed}
|
||||
// /files always shows the rail collapsed - force it here so a
|
||||
// deep-link/reload onto /files (no workbench transition) still
|
||||
// collapses, and a manual expand can't stick.
|
||||
collapsed={
|
||||
navigationState.workbench === "myFiles" || fileSidebarCollapsed
|
||||
}
|
||||
toggleAriaLabel={
|
||||
navigationState.workbench === "myFiles"
|
||||
? t("fileSidebar.leaveMyFiles", "Leave My Files")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/** Where a delete should remove the file from. */
|
||||
export type DeleteScope = "device" | "cloud" | "everywhere";
|
||||
|
||||
/**
|
||||
* Delete a file from server storage. Backend: DELETE /api/v1/storage/files/{id}
|
||||
* (owner-only). Treats 404 as already-deleted so double-deletes don't error.
|
||||
*/
|
||||
export async function deleteServerFile(remoteId: number): Promise<void> {
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/storage/files/${remoteId}`);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 404) return; // already gone on the server
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user