diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index df0d9fbc7d..607dfa4e5d 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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." diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f776687189..8e969fca87 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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." diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 632d9ea00d..87bcfab3f1 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -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( () => [ { @@ -384,6 +388,16 @@ const FileEditorThumbnail = ({ }, hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR, }, + { + id: "versionHistory", + icon: , + label: t("fileManager.versionHistory", "Version history"), + onClick: (e) => { + e.stopPropagation(); + setShowVersionHistory(true); + }, + hidden: (file.versionNumber ?? 1) <= 1, + }, { id: "close", icon: , @@ -399,6 +413,7 @@ const FileEditorThumbnail = ({ t, file.id, file.name, + file.versionNumber, isZipFile, isCBZ, isCBR, @@ -671,6 +686,11 @@ const FileEditorThumbnail = ({ file={file} /> )} + setShowVersionHistory(false)} + file={file} + /> ); }; diff --git a/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx new file mode 100644 index 0000000000..2eb57716f2 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx @@ -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; +} + +/** 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("everywhere"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + {showChoice ? ( + <> + + {t( + "filesPage.deleteFilesChoiceBody", + "Some of these files are saved both on this device and in the cloud. Where should they be deleted from?", + )} + + setScope(v as DeleteScope)} + > + + + + + + + + ) : ( + + {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 }, + )} + + )} + + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx index 2608e4b30a..aaab5d3b08 100644 --- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -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([]); const singleFileForChain = files.length === 1 ? files[0] : null; @@ -144,7 +152,11 @@ export function FileDetailsPanel({
{single ? ( <> -
+
{single.thumbnailUrl ? ( ) : ( @@ -174,36 +186,52 @@ export function FileDetailsPanel({ )}
-
- setFieldsOpen((o) => !o)} + aria-expanded={fieldsOpen} + > + {t("filesPage.fileInfo", "File info")} + - - - - -
+ + {fieldsOpen && ( +
+ + + + + +
+ )} {single.toolHistory && single.toolHistory.length > 0 && (
@@ -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 && ( - - )} + {versionChain.length > 1 && + (compactVersions && onOpenVersionHistory ? ( + + ) : ( + + ))} ) : (
@@ -246,120 +286,107 @@ export function FileDetailsPanel({ />
)} +
-
+ - {single && ( - - )} - - {/* 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, + })} + + + {/* 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 && ( - - - - )} - - {/* Save to server; shown when any selected file is local-only. When + + + )} + + {/* 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 && ( - - - - )} - -
+ + + )} +
{/* Single panel-level mount; gated on sharingEnabled. */} {single && sharingEnabled && ( @@ -372,309 +399,3 @@ export function FileDetailsPanel({ ); } - -function DetailField({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -/** 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>(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(); - 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 ( -
-
- - {t("filesPage.field.versionHistory", "Version journey")} - - {t("filesPage.versionsCount", "{{count}} versions", { - count: ordered.length, - })} - -
-
    - {rows.map((row, idx) => { - const isLast = idx === rows.length - 1; - if (row.kind === "ellipsis") { - return ( -
  1. -
    - - {!isLast && ( - - )} -
    - -
  2. - ); - } - 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 ( -
  3. -
    - - {!isLast && ( - - )} -
    -
    - -
    - {formatFileSize(v.size)} - {v.lastModified ? ( - <> - · - - {getFileDate({ lastModified: v.lastModified })} - - - ) : null} - {!isActive && ( - <> - - - - e.stopPropagation()} - > - - - - - } - onClick={() => onQuickView(v.id)} - > - {t("filesPage.viewVersion", "View this version")} - - } - onClick={() => onAddToWorkspace([v.id])} - > - {t( - "filesPage.openVersionInWorkspace", - "Open in workspace", - )} - - } - onClick={() => { - void downloadFileFromStorage(v); - }} - > - {t( - "filesPage.downloadVersion", - "Download this version", - )} - - - } - onClick={() => onRemove([v.id])} - > - {t( - "filesPage.removeVersion", - "Remove this version", - )} - - - - - )} -
    - {isExpanded && ( - // Filename + full cumulative tool chain. -
    - - {v.toolHistory && v.toolHistory.length > 0 && ( -
    - - {t( - "filesPage.field.toolHistoryAtVersion", - "Cumulative tool chain", - )} - - -
    - )} -
    - )} -
    -
  4. - ); - })} -
- {collapsible && showAllCollapsed && ( - - )} -
- ); -} - -/** Translated tool name via `home.{toolId}.title`. */ -function ToolLabel({ toolId }: { toolId: ToolId }) { - const { t } = useTranslation(); - return {t(`home.${toolId}.title`, toolId)}; -} diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 46644b1b7d..01e9b9355a 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -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")} - } - onClick={(e) => { - e.stopPropagation(); - onQuickView(); - }} - > - {t("filesPage.quickView", "Quick view")} - } @@ -834,6 +830,17 @@ function FileCard({ )} + {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} 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")} - } - onClick={(e) => { - e.stopPropagation(); - onQuickView(); - }} - > - {t("filesPage.quickView", "Quick view")} - } @@ -1424,6 +1427,17 @@ function FileRow({ )} + {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} (null); + // Version-history modal target (opened from the card kebab). + const [versionHistoryFile, setVersionHistoryFile] = + useState(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. @@ -1204,19 +1195,6 @@ export default function FileManagerView() { {addLabel} - {selectedFiles.length === 1 && ( - - - - )} {/* 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); + } + }} /> )} @@ -1643,6 +1627,22 @@ export default function FileManagerView() { }} /> + {/* Cloud-aware delete; offers local/cloud/both when a file lives in both. */} + + + {/* Version journey in a modal (opened from the card kebab). */} + setVersionHistoryFile(null)} + file={versionHistoryFile} + onChanged={refresh} + /> + {/* Save-to-server modal; keyed on target so updates don't retarget. */} s.id).join(",")}`} diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index f1e0e3db00..dcc11bd0f3 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -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; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx new file mode 100644 index 0000000000..077924155d --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx @@ -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([]); + 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 ( + + {loading ? ( +
+ +
+ ) : chain.length > 1 && file ? ( + + ) : ( + + {t( + "filesPage.versionHistoryEmpty", + "This file has no earlier versions.", + )} + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx new file mode 100644 index 0000000000..937db8c384 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -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 ( +
+ {label} + {value} +
+ ); +} + +/** 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 {t(`home.${toolId}.title`, toolId)}; +} + +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>(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(); + 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 ( +
+
+ + {t("filesPage.field.versionHistory", "Version journey")} + + {t("filesPage.versionsCount", "{{count}} versions", { + count: ordered.length, + })} + +
+
    + {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.kind === "ellipsis") { + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
  2. + ); + } + 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 ( +
  3. +
    + + {!isLast && ( + + )} +
    +
    + +
    + {formatFileSize(v.size)} + {v.lastModified ? ( + <> + · + + {getFileDate({ lastModified: v.lastModified })} + + + ) : null} + {/* Kebab on every row - the original/active version also + needs download + open-in-workspace. */} + + + + e.stopPropagation()} + > + + + + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t("filesPage.removeVersion", "Remove this version")} + + + +
    + {isExpanded && ( + // Filename + full cumulative tool chain. +
    + + {v.toolHistory && v.toolHistory.length > 0 && ( +
    + + {t( + "filesPage.field.toolHistoryAtVersion", + "Cumulative tool chain", + )} + + +
    + )} +
    + )} +
    +
  4. + ); + })} +
+ {collapsible && showAllCollapsed && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f890cc159d..3a14b0fb59 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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( // 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(null); const displayName = authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); @@ -220,6 +229,21 @@ const FileSidebar = forwardRef( // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); 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(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( + 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( 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( : (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 ( ( 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(
+ {/* Kebab "Save to cloud" upload modal (one file at a time). */} + 0)} + onClose={() => setSaveToServerTarget(null)} + files={saveToServerTarget ?? []} + onUploaded={refreshStubs} + /> + + {/* Kebab "Version history" modal. */} + setVersionHistoryTarget(null)} + file={versionHistoryTarget} + onChanged={refreshStubs} + /> + + {/* Cloud-aware delete choice (only opened for cloud-uploaded files). */} + setDeleteTarget(null)} + onConfirm={handleConfirmSidebarDelete} + /> + {/* Bottom bar: user name + settings */} 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} + {isUploadedToCloud && ( + + + + + + )} {policies.length > 0 && ( {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => ( @@ -355,6 +392,73 @@ export function FileItem({ sx={{ fontSize: "1.1rem" }} /> + + {(onDelete || + onSaveToCloud || + (hasVersionHistory && onVersionHistory)) && ( + + + + + e.stopPropagation()}> + {hasVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(fileId); + }} + > + {t("fileSidebar.fileItem.versionHistory", "Version history")} + + )} + {canSaveToCloud && onSaveToCloud && ( + + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + )} + + )} + {onDelete && ( + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {t("fileSidebar.fileItem.delete", "Delete")} + + )} + + + )} {useRasterThumb && diff --git a/frontend/editor/src/core/contexts/FilesPageContext.tsx b/frontend/editor/src/core/contexts/FilesPageContext.tsx index 914088e33b..6774798aae 100644 --- a/frontend/editor/src/core/contexts/FilesPageContext.tsx +++ b/frontend/editor/src/core/contexts/FilesPageContext.tsx @@ -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; + /** Queue files for deletion - opens the DeleteFilesDialog. */ removeFiles: (fileIds: FileId[]) => Promise; + /** 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; /** 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([]); + 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, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index f1e5158daf..4eed533928 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -486,7 +486,12 @@ export default function HomePage() { { + 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; + } +}