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:
Anthony Stirling
2026-06-18 10:33:18 +00:00
committed by GitHub
parent 2b05865a84
commit 9a3bc6b47f
16 changed files with 1433 additions and 559 deletions
@@ -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,
+6 -1
View File
@@ -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;
}
}