mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Add server-side folders and files page UI (#6383)
This commit is contained in:
@@ -32,6 +32,7 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -125,39 +126,41 @@ export function AppProviders({
|
||||
enableUrlSync={true}
|
||||
enablePersistence={true}
|
||||
>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
<FolderProvider>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { FileId } from "@app/types/file";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { Tool } from "@app/types/tool";
|
||||
import MobileLayout from "@app/components/fileManager/MobileLayout";
|
||||
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||
@@ -20,8 +19,14 @@ import { loadScript } from "@app/utils/scriptLoader";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
|
||||
/**
|
||||
* Structural prop: anything that exposes an optional `supportedFormats`
|
||||
* string array. Both `Tool` (from `@app/types/tool`) and `ToolRegistryEntry`
|
||||
* (from `@app/data/toolsTaxonomy`) satisfy this, so callers can pass either
|
||||
* without an `as any` cast.
|
||||
*/
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
selectedTool?: { supportedFormats?: string[] } | null;
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
|
||||
interface DeleteFolderDialogProps {
|
||||
opened: boolean;
|
||||
folder: FolderRecord | null;
|
||||
/** Number of files inside the folder (and subtree). */
|
||||
fileCount: number;
|
||||
onClose: () => void;
|
||||
/** Confirm; `deleteContents` is true when the user opted in to delete files. */
|
||||
onConfirm: (deleteContents: boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function DeleteFolderDialog({
|
||||
opened,
|
||||
folder,
|
||||
fileCount,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteFolderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteContents, setDeleteContents] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setDeleteContents(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("filesPage.deleteFolderTitle", "Delete folder?")}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', {
|
||||
name: folder.name,
|
||||
})}
|
||||
</Text>
|
||||
{fileCount > 0 && (
|
||||
<Checkbox
|
||||
checked={deleteContents}
|
||||
onChange={(e) => setDeleteContents(e.currentTarget.checked)}
|
||||
disabled={submitting}
|
||||
label={t(
|
||||
"filesPage.deleteFolderContents",
|
||||
"Also delete {{count}} file(s) inside the folder",
|
||||
{ count: fileCount },
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{fileCount > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{deleteContents
|
||||
? t(
|
||||
"filesPage.deleteFolderContentsWarning",
|
||||
"Files will be permanently removed and cannot be recovered.",
|
||||
)
|
||||
: t(
|
||||
"filesPage.deleteFolderKeepHint",
|
||||
"Files inside will be moved to All files.",
|
||||
)}
|
||||
</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={async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm(deleteContents);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(
|
||||
"filesPage.deleteFolderError",
|
||||
"Could not delete the folder. Try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("filesPage.delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Badge, Button, Menu, 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 { FolderRecord } from "@app/types/folder";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import {
|
||||
downloadFileFromStorage,
|
||||
downloadMultipleFiles,
|
||||
} from "@app/utils/downloadUtils";
|
||||
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";
|
||||
|
||||
interface FileDetailsPanelProps {
|
||||
selectedFileIds: FileId[];
|
||||
fileMap: Map<FileId, StirlingFileStub>;
|
||||
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;
|
||||
}
|
||||
|
||||
export function FileDetailsPanel({
|
||||
selectedFileIds,
|
||||
fileMap,
|
||||
currentFolder,
|
||||
onClose,
|
||||
onAddToWorkspace,
|
||||
onQuickView,
|
||||
onMove,
|
||||
onRemove,
|
||||
onSaveToServer,
|
||||
}: FileDetailsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
const files = useMemo(
|
||||
() =>
|
||||
selectedFileIds
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((f): f is StirlingFileStub => Boolean(f)),
|
||||
[selectedFileIds, fileMap],
|
||||
);
|
||||
|
||||
// Hooks must run before any early return.
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = 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;
|
||||
useEffect(() => {
|
||||
if (!singleFileForChain) {
|
||||
setVersionChain([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const rootId = (singleFileForChain.originalFileId ??
|
||||
singleFileForChain.id) as FileId;
|
||||
fileStorage
|
||||
.getHistoryChainStubs(rootId)
|
||||
.then((chain) => {
|
||||
if (!cancelled) setVersionChain(chain);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load version history", err);
|
||||
if (!cancelled) setVersionChain([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleFileForChain]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const single = files.length === 1 ? files[0]! : null;
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : "";
|
||||
// Files still needing a server upload; drives Save-to-server visibility.
|
||||
const localOnlyFiles = files.filter((f) => f.remoteStorageId == null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (single) {
|
||||
await downloadFileFromStorage(single);
|
||||
} else {
|
||||
await downloadMultipleFiles(files);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Download failed", err);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="files-page-details"
|
||||
aria-label={t("filesPage.details", "Details")}
|
||||
>
|
||||
<div className="files-page-details-header">
|
||||
<strong>
|
||||
{single
|
||||
? t("filesPage.details", "Details")
|
||||
: t("filesPage.detailsCount", "{{count}} files selected", {
|
||||
count: files.length,
|
||||
})}
|
||||
</strong>
|
||||
<Tooltip
|
||||
label={t("filesPage.closeDetails", "Close details")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onClose}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="files-page-details-body">
|
||||
{single ? (
|
||||
<>
|
||||
<div className="files-page-details-thumb">
|
||||
{single.thumbnailUrl ? (
|
||||
<img src={single.thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: "3rem", color: "var(--text-muted)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, wordBreak: "break-word", flex: 1 }}>
|
||||
{single.name}
|
||||
</h3>
|
||||
{ext && (
|
||||
// Custom span; Mantine Badge default rendered invisible in dark mode.
|
||||
<span className="files-page-details-ext-tag">{ext}</span>
|
||||
)}
|
||||
{(single.versionNumber ?? 1) > 1 && (
|
||||
<Badge size="sm" variant="filled" color="blue">
|
||||
v{single.versionNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
{t("filesPage.field.toolHistory", "Tool history")}
|
||||
</div>
|
||||
<ToolChain
|
||||
toolChain={single.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Version journey. Each tool run writes a new StirlingFile
|
||||
with the same `originalFileId` and an incremented
|
||||
`versionNumber`, so the chain reconstructs the edit
|
||||
timeline. The previous file manager exposed this and the
|
||||
refactored one had silently dropped it; this revival also
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="files-page-details-fieldlist">
|
||||
<DetailField
|
||||
label={t("filesPage.field.totalSize", "Total size")}
|
||||
value={formatFileSize(totalSize)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("filesPage.field.count", "Files")}
|
||||
value={String(files.length)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}
|
||||
>
|
||||
<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
|
||||
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 set storage.sharing.enabled=true to turn on share links and per-user access.",
|
||||
)}
|
||||
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)}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Button>
|
||||
{/* Save to server; shown when any selected file is local-only. */}
|
||||
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => onRemove(selectedFileIds)}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Single panel-level mount; gated on sharingEnabled. */}
|
||||
{single && sharingEnabled && (
|
||||
<ShareManagementModal
|
||||
opened={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
file={single}
|
||||
/>
|
||||
)}
|
||||
</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>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import ComputerIcon from "@mui/icons-material/Computer";
|
||||
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||
import GroupIcon from "@mui/icons-material/Group";
|
||||
|
||||
import { FileOrigin } from "@app/components/filesPage/fileOrigin";
|
||||
|
||||
interface FileOriginBadgeProps {
|
||||
origin: FileOrigin;
|
||||
/** Compact (icon-only) vs full (icon + text). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
base: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.1rem 0.4rem",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.04em",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
local: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--text-muted, #6b7280) 16%, transparent)",
|
||||
color: "var(--text-secondary)",
|
||||
},
|
||||
cloud: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent-interactive, #6366f1) 16%, transparent)",
|
||||
color: "var(--accent-interactive, #6366f1)",
|
||||
},
|
||||
shared: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--mantine-color-orange-6, #f97316) 16%, transparent)",
|
||||
color: "var(--mantine-color-orange-6, #f97316)",
|
||||
},
|
||||
};
|
||||
|
||||
export function FileOriginBadge({
|
||||
origin,
|
||||
compact = false,
|
||||
}: FileOriginBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const config = (() => {
|
||||
switch (origin) {
|
||||
case "cloud":
|
||||
return {
|
||||
label: t("filesPage.origin.cloud", "Cloud"),
|
||||
icon: <CloudDoneIcon style={{ fontSize: "0.85rem" }} />,
|
||||
style: styles.cloud,
|
||||
tooltip: t(
|
||||
"filesPage.origin.cloudHint",
|
||||
"Stored on the Stirling server",
|
||||
),
|
||||
};
|
||||
case "shared-with-me":
|
||||
return {
|
||||
label: t("filesPage.origin.shared", "Shared"),
|
||||
icon: <GroupIcon style={{ fontSize: "0.85rem" }} />,
|
||||
style: styles.shared,
|
||||
tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"),
|
||||
};
|
||||
case "local":
|
||||
default:
|
||||
return {
|
||||
label: t("filesPage.origin.local", "Local"),
|
||||
icon: <ComputerIcon style={{ fontSize: "0.85rem" }} />,
|
||||
style: styles.local,
|
||||
tooltip: t(
|
||||
"filesPage.origin.localHint",
|
||||
"Only stored in this browser",
|
||||
),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
const badge = (
|
||||
<span style={{ ...styles.base, ...config.style }}>
|
||||
{config.icon}
|
||||
{!compact && config.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={config.tooltip} withinPortal>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Inline colour + icon picker for a folder. Rendered inside the folder
|
||||
* kebab menu (Mantine Menu.Item with a custom body) so the menu can
|
||||
* still own close-on-outside-click behaviour.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
import { FolderRecord, FOLDER_COLOR_PALETTE } from "@app/types/folder";
|
||||
import {
|
||||
FOLDER_ICONS,
|
||||
FolderIconOption,
|
||||
} from "@app/components/filesPage/folderIcons";
|
||||
|
||||
interface FolderAppearancePickerProps {
|
||||
folder: FolderRecord;
|
||||
onChange: (next: { color?: string; icon?: string | null }) => void;
|
||||
/** When true, all colour + icon buttons are unresponsive (e.g. while offline). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FolderAppearancePicker({
|
||||
folder,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: FolderAppearancePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
minWidth: "16rem",
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
pointerEvents: disabled ? "none" : undefined,
|
||||
}}
|
||||
aria-disabled={disabled || undefined}
|
||||
>
|
||||
<Section label={t("filesPage.appearance.colour", "Colour")}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(8, 1fr)",
|
||||
gap: "0.35rem",
|
||||
}}
|
||||
>
|
||||
{FOLDER_COLOR_PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t(
|
||||
"filesPage.appearance.useColour",
|
||||
"Use colour {{c}}",
|
||||
{ c },
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange({ color: c });
|
||||
}}
|
||||
style={{
|
||||
width: "1.6rem",
|
||||
height: "1.6rem",
|
||||
borderRadius: "50%",
|
||||
border:
|
||||
folder.color === c
|
||||
? "2px solid var(--text-primary)"
|
||||
: "2px solid transparent",
|
||||
background: c,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
outlineOffset: "2px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t("filesPage.appearance.icon", "Icon")}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(6, 1fr)",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{FOLDER_ICONS.map((icon) => (
|
||||
<IconButton
|
||||
key={icon.id}
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
selected={
|
||||
(icon.id === "none" && !folder.icon) || folder.icon === icon.id
|
||||
}
|
||||
onClick={() =>
|
||||
onChange({ icon: icon.id === "none" ? null : icon.id })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
icon,
|
||||
selected,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: FolderIconOption;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={icon.label} withinPortal>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={icon.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
style={{
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "1.1rem",
|
||||
borderRadius: "0.4rem",
|
||||
background: selected ? "var(--hover-bg)" : "transparent",
|
||||
border: selected
|
||||
? "1px solid var(--accent-interactive, #6366f1)"
|
||||
: "1px solid transparent",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{icon.glyph || "-"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
interface FolderNameDialogProps {
|
||||
opened: boolean;
|
||||
title: string;
|
||||
initialName?: string;
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function FolderNameDialog({
|
||||
opened,
|
||||
title,
|
||||
initialName = "",
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: FolderNameDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(initialName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setValue(initialName);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened, initialName]);
|
||||
|
||||
const submit = async () => {
|
||||
const name = value.trim();
|
||||
if (!name) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(name);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// Keep dialog open so the user can retry. Closing on error was a
|
||||
// silent failure (the dialog vanished, but the folder was never
|
||||
// created - user thinks success, sees no folder).
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(
|
||||
"filesPage.folderName.error",
|
||||
"Could not save folder. Try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
centered
|
||||
size="sm"
|
||||
keepMounted
|
||||
transitionProps={{ duration: 0 }}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={t("filesPage.folderName.placeholder", "Folder name")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
maxLength={120}
|
||||
aria-label={t("filesPage.folderName.label", "Folder name")}
|
||||
/>
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<ErrorOutlineIcon fontSize="small" />}
|
||||
variant="light"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("filesPage.folderName.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
loading={submitting}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Stylised folder thumbnail.
|
||||
*
|
||||
* A custom SVG folder shape that takes its accent colour from
|
||||
* `FolderRecord.color` and shows the contained-file count as a small badge
|
||||
* in the corner. Renders proportionally inside whatever container it's
|
||||
* placed in (file card thumb, list-row icon).
|
||||
*/
|
||||
|
||||
import React, { useId } from "react";
|
||||
|
||||
interface FolderThumbnailProps {
|
||||
color?: string;
|
||||
fileCount?: number;
|
||||
/** Visual scale - "thumb" for cards, "row" for list rows, "tree" for nav. */
|
||||
size?: "thumb" | "row" | "tree";
|
||||
/** Optional glyph (emoji) overlaid in the centre of the front pocket. */
|
||||
iconGlyph?: string;
|
||||
}
|
||||
|
||||
const SIZE_PX: Record<NonNullable<FolderThumbnailProps["size"]>, number> = {
|
||||
thumb: 96,
|
||||
row: 22,
|
||||
tree: 18,
|
||||
};
|
||||
|
||||
export function FolderThumbnail({
|
||||
color,
|
||||
fileCount,
|
||||
size = "thumb",
|
||||
iconGlyph,
|
||||
}: FolderThumbnailProps) {
|
||||
const accent = color ?? "var(--accent-interactive, #6366f1)";
|
||||
const px = SIZE_PX[size];
|
||||
const showBadge = size === "thumb" && (fileCount ?? 0) > 0;
|
||||
// Per-instance unique ids - `${color}` previously embedded `#` and CSS
|
||||
// function syntax in the id, which broke `url(#...)` references (Safari
|
||||
// would parse the inner `#` as a new fragment start and the lookup
|
||||
// would miss entirely, leaving the folder shape unfilled).
|
||||
const reactId = useId();
|
||||
const backId = `${reactId}-back`;
|
||||
const frontId = `${reactId}-front`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: px,
|
||||
height: Math.round(px * 0.8),
|
||||
display: "inline-block",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 100 80"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={backId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity="0.75" />
|
||||
</linearGradient>
|
||||
<linearGradient id={frontId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Back panel - symmetric viewBox so the folder is visually
|
||||
centred (6px breathing room on every side). */}
|
||||
<path
|
||||
d="M4 12 Q4 6 10 6 H38 L46 14 H90 Q96 14 96 20 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
|
||||
fill={`url(#${backId})`}
|
||||
/>
|
||||
|
||||
{/* Paper peeking out (lighter) */}
|
||||
<rect
|
||||
x="14"
|
||||
y="22"
|
||||
width="72"
|
||||
height="36"
|
||||
rx="4"
|
||||
fill="rgba(255, 255, 255, 0.85)"
|
||||
/>
|
||||
<rect
|
||||
x="18"
|
||||
y="18"
|
||||
width="64"
|
||||
height="32"
|
||||
rx="4"
|
||||
fill="rgba(255, 255, 255, 0.55)"
|
||||
/>
|
||||
|
||||
{/* Front pocket */}
|
||||
<path
|
||||
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
|
||||
fill={`url(#${frontId})`}
|
||||
/>
|
||||
|
||||
{/* Subtle highlight on the lip */}
|
||||
<path
|
||||
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V32 H4 Z"
|
||||
fill="rgba(255, 255, 255, 0.18)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{iconGlyph && size === "thumb" && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "28% 0 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: `${px * 0.28}px`,
|
||||
lineHeight: 1,
|
||||
pointerEvents: "none",
|
||||
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.25))",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{iconGlyph}
|
||||
</span>
|
||||
)}
|
||||
{iconGlyph && size === "row" && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "30% 0 0 0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: `${px * 0.45}px`,
|
||||
lineHeight: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{iconGlyph}
|
||||
</span>
|
||||
)}
|
||||
{showBadge && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-0.35rem",
|
||||
right: "-0.35rem",
|
||||
minWidth: "1.4rem",
|
||||
height: "1.4rem",
|
||||
borderRadius: "999px",
|
||||
background: "var(--bg-surface, #fff)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: accent,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 0.35rem",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.12)",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{fileCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* Secondary navigator panel that slides out from the main FileSidebar
|
||||
when the user enters the My Files workbench.
|
||||
|
||||
Pattern:
|
||||
- Outer panel is a flex item whose `width` transitions. The inner
|
||||
content keeps a fixed natural width and the outer `overflow: hidden`
|
||||
clips it during the slide so the user sees the panel grow from the
|
||||
edge rather than the content shrinking.
|
||||
- Inner content separately fades + nudges in for an attentive feel.
|
||||
|
||||
Styled to mirror the main FileSidebar - same toolbar background, same
|
||||
section-header treatment, same icon weight - so the two read as one
|
||||
unified surface. */
|
||||
|
||||
.folder-tree-panel {
|
||||
width: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-toolbar);
|
||||
border-right: 0 solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-tree-panel[data-active="true"] {
|
||||
width: var(--folder-tree-panel-width, 16rem);
|
||||
border-right-width: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.folder-tree-panel-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
transform: translateX(-1rem);
|
||||
/* Only fade/slide; width is driven by the inline custom property so
|
||||
* dragging the resizer doesn't animate. */
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.folder-tree-panel[data-active="true"] .folder-tree-panel-inner {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0.04s;
|
||||
}
|
||||
|
||||
/* Drag handle on the right edge. */
|
||||
.folder-tree-panel-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.folder-tree-panel-resizer:hover,
|
||||
.folder-tree-panel-resizer:focus-visible {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 35%,
|
||||
transparent
|
||||
);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Section header - matches .file-sidebar-section-header in FileSidebar.css */
|
||||
.folder-tree-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px 6px 18px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-tree-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the
|
||||
slide-out folder navigator reads as a continuation of the main sidebar's
|
||||
design language. Same row height, padding, font weight, muted icon
|
||||
treatment. The hover/active state uses the same --hover-bg pill that
|
||||
the sidebar's other rows do, with no heavy accent bar. */
|
||||
|
||||
.files-page-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-node:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-active {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-drop-target {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #6366f1) 12%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.files-page-tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-toggle svg {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-spacer {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.files-page-tree-icon svg {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Cap the user width on narrow viewports so the tree can't squeeze
|
||||
* out the file grid. The custom property is still honoured but capped. */
|
||||
.folder-tree-panel[data-active="true"] {
|
||||
width: min(var(--folder-tree-panel-width, 14rem), 14rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/** Folder tree navigator panel rendered next to FileSidebar on /files. */
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
|
||||
import { useFilesPage } from "@app/contexts/FilesPageContext";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord } from "@app/types/folder";
|
||||
import {
|
||||
MIN_WIDTH,
|
||||
MAX_WIDTH,
|
||||
clamp,
|
||||
computeAutoFitWidth,
|
||||
loadPersistedWidth,
|
||||
savePersistedWidth,
|
||||
} from "@app/components/filesPage/folderTreeWidth";
|
||||
|
||||
import "@app/components/filesPage/FolderTreePanel.css";
|
||||
|
||||
interface FolderTreePanelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
fileCountsByFolder,
|
||||
openNewFolderDialog,
|
||||
openRenameFolderDialog,
|
||||
promptDeleteFolder,
|
||||
moveFilesTo,
|
||||
} = useFilesPage();
|
||||
const folders = useFolders();
|
||||
const rootLabel = t("filesPage.allFiles", "All files");
|
||||
|
||||
const [width, setWidth] = useState<number>(() => {
|
||||
const persisted = loadPersistedWidth();
|
||||
return persisted ?? 256;
|
||||
});
|
||||
const userSetRef = useRef<boolean>(loadPersistedWidth() !== null);
|
||||
|
||||
// Auto-fit to the longest folder name on first render and whenever the
|
||||
// folder list grows; skipped once the user manually resizes.
|
||||
useEffect(() => {
|
||||
if (userSetRef.current) return;
|
||||
const auto = computeAutoFitWidth(folders.folders, rootLabel);
|
||||
setWidth(auto);
|
||||
}, [folders.folders, rootLabel]);
|
||||
|
||||
const dragStateRef = useRef<{
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
const next = clamp(state.startWidth + (e.clientX - state.startX));
|
||||
setWidth(next);
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
dragStateRef.current = null;
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.removeProperty("cursor");
|
||||
document.body.style.removeProperty("user-select");
|
||||
userSetRef.current = true;
|
||||
setWidth((current) => {
|
||||
savePersistedWidth(current);
|
||||
return current;
|
||||
});
|
||||
}, [onMouseMove]);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStateRef.current = { startX: e.clientX, startWidth: width };
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[onMouseMove, onMouseUp, width],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const step = e.shiftKey ? 32 : 8;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowLeft") next = clamp(width - step);
|
||||
else if (e.key === "ArrowRight") next = clamp(width + step);
|
||||
else if (e.key === "Home") next = MIN_WIDTH;
|
||||
else if (e.key === "End") next = MAX_WIDTH;
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
userSetRef.current = true;
|
||||
setWidth(next);
|
||||
savePersistedWidth(next);
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="folder-tree-panel"
|
||||
data-active={String(active)}
|
||||
aria-hidden={!active}
|
||||
style={
|
||||
active
|
||||
? ({
|
||||
"--folder-tree-panel-width": `${width}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="folder-tree-panel-inner">
|
||||
<div className="folder-tree-panel-header">
|
||||
<span className="folder-tree-panel-title">
|
||||
{t("filesPage.myFiles", "My Files")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FolderTreeSidebar
|
||||
fileCounts={fileCountsByFolder}
|
||||
onRequestNewFolder={openNewFolderDialog}
|
||||
onRenameFolder={(folder: FolderRecord) =>
|
||||
openRenameFolderDialog(folder)
|
||||
}
|
||||
onDeleteFolder={promptDeleteFolder}
|
||||
onMoveFilesIntoFolder={async (
|
||||
targetId: FolderId | null,
|
||||
fileIds: FileId[],
|
||||
) => {
|
||||
if (fileIds.length === 0) return;
|
||||
await moveFilesTo(fileIds, targetId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{active && (
|
||||
<div
|
||||
className="folder-tree-panel-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
aria-valuemax={MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
aria-label={t(
|
||||
"filesPage.resizeFolderTree",
|
||||
"Resize folder tree (arrow keys, Shift for bigger steps)",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
onKeyDown={onKeyDown}
|
||||
onDoubleClick={() => {
|
||||
const auto = computeAutoFitWidth(folders.folders, rootLabel);
|
||||
userSetRef.current = false;
|
||||
setWidth(auto);
|
||||
savePersistedWidth(auto);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import DevicesOtherIcon from "@mui/icons-material/DevicesOther";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
import {
|
||||
FolderId,
|
||||
FolderRecord,
|
||||
FolderTreeNode,
|
||||
ROOT_FOLDER_ID,
|
||||
} from "@app/types/folder";
|
||||
import { useFilesPage } from "@app/contexts/FilesPageContext";
|
||||
import {
|
||||
FILES_PAGE_DRAG_TYPE,
|
||||
parseFilesPageDragPayload,
|
||||
serialiseFilesPageDragPayload,
|
||||
} from "@app/components/filesPage/dragDrop";
|
||||
import { useDropTarget } from "@app/components/filesPage/useDropTarget";
|
||||
|
||||
/**
|
||||
* Hard cap on folder-tree render depth. The backend already enforces an
|
||||
* application-level depth limit via cycle detection + folder-count cap,
|
||||
* and React's render stack handles ~50 nested components comfortably,
|
||||
* so this is purely defensive against a corrupted IDB cache producing
|
||||
* a chain deeper than the server would allow.
|
||||
*/
|
||||
const MAX_TREE_DEPTH = 50;
|
||||
|
||||
interface FolderTreeSidebarProps {
|
||||
fileCounts: Map<FolderId | null, number>;
|
||||
onRequestNewFolder: (parentId: FolderId | null) => void;
|
||||
onRenameFolder: (folder: FolderRecord) => void;
|
||||
onDeleteFolder: (folder: FolderRecord) => void;
|
||||
/**
|
||||
* Move the *dragged* files (from the drop payload) into the target folder.
|
||||
* Earlier signature took only the folder id and the parent then used the
|
||||
* current selection - which silently moved the wrong files whenever the
|
||||
* user dragged something that wasn't in the selection.
|
||||
*/
|
||||
onMoveFilesIntoFolder: (
|
||||
folderId: FolderId | null,
|
||||
fileIds: FileId[],
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// This component is always rendered inside FolderTreePanel, which supplies
|
||||
// its own <aside> chrome and "New folder at root" toolbar control. An
|
||||
// earlier `embed` prop selected between an embedded list and a standalone
|
||||
// aside+header layout; the standalone layout was unused and its "New
|
||||
// folder at root" ActionIcon was not gated by `serverReachable`, so if
|
||||
// anyone re-wired the component into a non-embed surface they'd ship an
|
||||
// always-enabled mutation button against a possibly-offline server.
|
||||
// Deleted to remove the trap.
|
||||
export function FolderTreeSidebar({
|
||||
fileCounts,
|
||||
onRequestNewFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onMoveFilesIntoFolder,
|
||||
}: FolderTreeSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tree, currentFolderId, setCurrentFolderId } = useFolders();
|
||||
const { currentTab, setCurrentTab, moveFolderTo } = useFilesPage();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="files-page-tree-list"
|
||||
role="tree"
|
||||
aria-label={t("filesPage.tree", "Folders")}
|
||||
>
|
||||
<RootRow
|
||||
fileCount={fileCounts.get(ROOT_FOLDER_ID) ?? 0}
|
||||
isActive={
|
||||
currentFolderId === ROOT_FOLDER_ID &&
|
||||
(currentTab === "all" || currentTab === "cloud")
|
||||
}
|
||||
onSelect={() => {
|
||||
// Picking the root re-enters the cloud bucket - also switch out
|
||||
// of any virtual tab so the user lands somewhere consistent.
|
||||
if (currentTab !== "all" && currentTab !== "cloud") {
|
||||
setCurrentTab("all");
|
||||
}
|
||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||
}}
|
||||
onDropFiles={(fileIds) =>
|
||||
onMoveFilesIntoFolder(ROOT_FOLDER_ID, fileIds)
|
||||
}
|
||||
/>
|
||||
<LocalRow
|
||||
isActive={currentTab === "local"}
|
||||
onSelect={() => setCurrentTab("local")}
|
||||
/>
|
||||
{tree.map((node) => (
|
||||
<TreeNodeRow
|
||||
key={node.folder.id}
|
||||
node={node}
|
||||
fileCounts={fileCounts}
|
||||
currentFolderId={currentFolderId}
|
||||
// Same dance as RootRow: clicking a cloud folder must drop the
|
||||
// virtual-tab highlight (Local/Recent/Shared), otherwise the row
|
||||
// AND the tab both look "active" simultaneously.
|
||||
onSelect={(id) => {
|
||||
if (currentTab !== "all" && currentTab !== "cloud") {
|
||||
setCurrentTab("all");
|
||||
}
|
||||
setCurrentFolderId(id);
|
||||
}}
|
||||
onMoveFolder={async (folderId, newParentId) => {
|
||||
// Route through filesPage.moveFolderTo so the cycle case
|
||||
// surfaces an error banner instead of silently no-op'ing.
|
||||
await moveFolderTo(folderId, newParentId);
|
||||
}}
|
||||
onMoveFiles={onMoveFilesIntoFolder}
|
||||
onRequestNewFolder={onRequestNewFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RootRowProps {
|
||||
fileCount: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropFiles: (fileIds: FileId[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function RootRow({ fileCount, isActive, onSelect, onDropFiles }: RootRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setError } = useFolders();
|
||||
const { handlers, isOver } = useDropTarget({
|
||||
dragType: FILES_PAGE_DRAG_TYPE,
|
||||
onDrop: (e) => {
|
||||
const payload = parseFilesPageDragPayload(e.dataTransfer);
|
||||
if (!payload) return;
|
||||
if (payload.kind === "files") {
|
||||
Promise.resolve(onDropFiles(payload.fileIds)).catch((err) => {
|
||||
console.error("[RootRow] file drop failed", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? t("filesPage.error.moveFilesFailedDetail", {
|
||||
message: err.message,
|
||||
defaultValue: `Could not move files: ${err.message}`,
|
||||
})
|
||||
: t("filesPage.error.moveFilesFailed", "Could not move files."),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
className={`files-page-tree-node${isActive ? " is-active" : ""}${
|
||||
isOver ? " is-drop-target" : ""
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
{...handlers}
|
||||
>
|
||||
<span className="files-page-tree-spacer" />
|
||||
<span className="files-page-tree-icon">
|
||||
<HomeIcon fontSize="small" />
|
||||
</span>
|
||||
<span className="files-page-tree-name">
|
||||
{t("filesPage.allFiles", "All files")}
|
||||
</span>
|
||||
<span className="files-page-tree-count">{fileCount}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocalRowProps {
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned pseudo-folder row that selects the Local tab. Local files don't
|
||||
* belong to a folder (folders are a cloud concept) so this row is not a
|
||||
* drop target and has no count badge - the Local view scopes by predicate
|
||||
* (`remoteStorageId == null`), not by folderId.
|
||||
*/
|
||||
function LocalRow({ isActive, onSelect }: LocalRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
className={`files-page-tree-node${isActive ? " is-active" : ""}`}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="files-page-tree-spacer" />
|
||||
<span className="files-page-tree-icon">
|
||||
<DevicesOtherIcon fontSize="small" />
|
||||
</span>
|
||||
<span className="files-page-tree-name">
|
||||
{t("filesPage.tabName.local", "Local")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeNodeRowProps {
|
||||
node: FolderTreeNode;
|
||||
fileCounts: Map<FolderId | null, number>;
|
||||
currentFolderId: FolderId | null;
|
||||
onSelect: (id: FolderId) => void;
|
||||
onMoveFolder: (
|
||||
folderId: FolderId,
|
||||
newParentId: FolderId | null,
|
||||
) => Promise<void> | void;
|
||||
onMoveFiles: (
|
||||
folderId: FolderId | null,
|
||||
fileIds: FileId[],
|
||||
) => Promise<void> | void;
|
||||
onRequestNewFolder: (parentId: FolderId | null) => void;
|
||||
onRenameFolder: (folder: FolderRecord) => void;
|
||||
onDeleteFolder: (folder: FolderRecord) => void;
|
||||
}
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
fileCounts,
|
||||
currentFolderId,
|
||||
onSelect,
|
||||
onMoveFolder,
|
||||
onMoveFiles,
|
||||
onRequestNewFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
}: TreeNodeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { serverReachable, setError } = useFolders();
|
||||
const { currentTab } = useFilesPage();
|
||||
const offlineHint = t(
|
||||
"filesPage.offlineNoFolderEdits",
|
||||
"Offline - folder changes are disabled.",
|
||||
);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Only highlight the folder row when we're actually in a cloud-rooted
|
||||
// view. Otherwise (Local/Recent/Shared tabs) it'd compete with the tab
|
||||
// highlight and confuse the user about "where they are".
|
||||
const isActive =
|
||||
currentFolderId === node.folder.id &&
|
||||
(currentTab === "all" || currentTab === "cloud");
|
||||
const hasChildren = node.children.length > 0;
|
||||
const indent = useMemo(
|
||||
() => ({ paddingLeft: `${14 + node.depth * 16}px` }),
|
||||
[node.depth],
|
||||
);
|
||||
|
||||
const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({
|
||||
dragType: FILES_PAGE_DRAG_TYPE,
|
||||
onDrop: (e) => {
|
||||
const payload = parseFilesPageDragPayload(e.dataTransfer);
|
||||
if (!payload) return;
|
||||
if (payload.kind === "files") {
|
||||
// Use payload.fileIds - not the current selection - so dragging a
|
||||
// non-selected file moves *that* file. Surface failures via the
|
||||
// shared error banner rather than letting them become unhandled
|
||||
// rejections that only the dev console sees.
|
||||
Promise.resolve(onMoveFiles(node.folder.id, payload.fileIds)).catch(
|
||||
(err) => {
|
||||
console.error("[TreeNodeRow] file drop failed", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? t("filesPage.error.moveFilesFailedDetail", {
|
||||
message: err.message,
|
||||
defaultValue: `Could not move files: ${err.message}`,
|
||||
})
|
||||
: t("filesPage.error.moveFilesFailed", "Could not move files."),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (payload.kind === "folder") {
|
||||
Promise.resolve(onMoveFolder(payload.folderId, node.folder.id)).catch(
|
||||
(err) => {
|
||||
console.error("[TreeNodeRow] folder drop failed", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? t("filesPage.error.moveFolderFailedDetail", {
|
||||
message: err.message,
|
||||
defaultValue: `Could not move folder: ${err.message}`,
|
||||
})
|
||||
: t(
|
||||
"filesPage.error.moveFolderFailed",
|
||||
"Could not move folder.",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.dataTransfer.setData(
|
||||
FILES_PAGE_DRAG_TYPE,
|
||||
serialiseFilesPageDragPayload({
|
||||
kind: "folder",
|
||||
folderId: node.folder.id,
|
||||
}),
|
||||
);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
},
|
||||
[node.folder.id],
|
||||
);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-selected={isActive}
|
||||
aria-expanded={hasChildren ? open : undefined}
|
||||
tabIndex={0}
|
||||
draggable
|
||||
style={indent}
|
||||
className={`files-page-tree-node${isActive ? " is-active" : ""}${
|
||||
isDropTarget ? " is-drop-target" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(node.folder.id)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
// Open the action menu on right-click rather than a native
|
||||
// window.prompt (unstyled, untranslatable, unusable on mobile).
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(node.folder.id);
|
||||
} else if (e.key === "ArrowRight" && hasChildren) {
|
||||
setOpen(true);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
{...dropHandlers}
|
||||
onDragStart={handleDragStart}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span
|
||||
className="files-page-tree-toggle"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="files-page-tree-spacer" />
|
||||
)}
|
||||
<span className="files-page-tree-icon">
|
||||
<FolderThumbnail color={node.folder.color} size="tree" />
|
||||
</span>
|
||||
<span className="files-page-tree-name">{node.folder.name}</span>
|
||||
<span className="files-page-tree-count">
|
||||
{fileCounts.get(node.folder.id) ?? 0}
|
||||
</span>
|
||||
<Menu
|
||||
opened={menuOpen}
|
||||
onChange={setMenuOpen}
|
||||
withinPortal
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={200}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
className="files-page-tree-kebab"
|
||||
aria-label={t(
|
||||
"filesPage.treeMenu.actions",
|
||||
"Folder actions for {{name}}",
|
||||
{ name: node.folder.name },
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen((o) => !o);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<EditIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRenameFolder(node.folder);
|
||||
}}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.treeMenu.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestNewFolder(node.folder.id);
|
||||
}}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteOutlineIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFolder(node.folder);
|
||||
}}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.treeMenu.delete", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
{open &&
|
||||
// Cap render recursion at MAX_TREE_DEPTH to guarantee a finite
|
||||
// call stack even if a future bug (or a hand-edited IDB cache)
|
||||
// produces a folder chain deeper than the server enforces. Any
|
||||
// realistic user tree stays well under this; the cap exists so
|
||||
// the renderer fails closed rather than blowing the JS stack.
|
||||
node.depth < MAX_TREE_DEPTH &&
|
||||
node.children.map((child) => (
|
||||
<TreeNodeRow
|
||||
key={child.folder.id}
|
||||
node={child}
|
||||
fileCounts={fileCounts}
|
||||
currentFolderId={currentFolderId}
|
||||
onSelect={onSelect}
|
||||
onMoveFolder={onMoveFolder}
|
||||
onMoveFiles={onMoveFiles}
|
||||
onRequestNewFolder={onRequestNewFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
|
||||
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
interface MoveToFolderDialogProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
folders: FolderRecord[];
|
||||
/** Folder being moved; excludes its descendants from destinations. */
|
||||
disabledFolderId?: FolderId | null;
|
||||
initialFolderId?: FolderId | null;
|
||||
onConfirm: (folderId: FolderId | null) => void | Promise<void>;
|
||||
/** Inline-create folder; new folder becomes the move target. */
|
||||
onCreateFolder?: (
|
||||
name: string,
|
||||
parentFolderId: FolderId | null,
|
||||
) => Promise<FolderRecord>;
|
||||
}
|
||||
|
||||
export function MoveToFolderDialog({
|
||||
opened,
|
||||
onClose,
|
||||
folders,
|
||||
disabledFolderId,
|
||||
initialFolderId = ROOT_FOLDER_ID,
|
||||
onConfirm,
|
||||
onCreateFolder,
|
||||
}: MoveToFolderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [target, setTarget] = useState<FolderId | null>(initialFolderId);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Inline create-folder state; revealed by the toggle.
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Reset when reopening with a new initial.
|
||||
React.useEffect(() => {
|
||||
if (opened) {
|
||||
setTarget(initialFolderId);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setCreating(false);
|
||||
}
|
||||
}, [opened, initialFolderId]);
|
||||
|
||||
/** Single-pass build of parent index, depths, and blocked descendants. */
|
||||
const { depthById, blocked, treeOrder } = useMemo(() => {
|
||||
const byParent = new Map<FolderId | null, FolderRecord[]>();
|
||||
for (const folder of folders) {
|
||||
const list = byParent.get(folder.parentFolderId) ?? [];
|
||||
list.push(folder);
|
||||
byParent.set(folder.parentFolderId, list);
|
||||
}
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-order DFS; truncates past MAX_TREE_DEPTH to prevent stack overflow.
|
||||
const MAX_TREE_DEPTH = 50;
|
||||
const order: FolderRecord[] = [];
|
||||
const depths = new Map<FolderId, number>();
|
||||
const visit = (parent: FolderId | null, depth: number) => {
|
||||
if (depth >= MAX_TREE_DEPTH) return;
|
||||
for (const child of byParent.get(parent) ?? []) {
|
||||
order.push(child);
|
||||
depths.set(child.id, depth);
|
||||
visit(child.id, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(null, 0);
|
||||
|
||||
const blockedSet = new Set<FolderId>();
|
||||
if (disabledFolderId) {
|
||||
const stack: FolderId[] = [disabledFolderId];
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
if (blockedSet.has(cur)) continue;
|
||||
blockedSet.add(cur);
|
||||
for (const child of byParent.get(cur) ?? []) {
|
||||
stack.push(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { depthById: depths, blocked: blockedSet, treeOrder: order };
|
||||
}, [disabledFolderId, folders]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("filesPage.moveDialog.title", "Move to folder")}
|
||||
centered
|
||||
size="md"
|
||||
keepMounted
|
||||
transitionProps={{ duration: 0 }}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"filesPage.moveDialog.hint",
|
||||
"Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left.",
|
||||
)}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-subtle)",
|
||||
borderRadius: "0.5rem",
|
||||
maxHeight: "20rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<FolderPick
|
||||
label={t("filesPage.allFiles", "All files")}
|
||||
isActive={target === ROOT_FOLDER_ID}
|
||||
disabled={false}
|
||||
depth={0}
|
||||
isRoot
|
||||
onPick={() => setTarget(ROOT_FOLDER_ID)}
|
||||
/>
|
||||
{treeOrder.map((folder) => (
|
||||
<FolderPick
|
||||
key={folder.id}
|
||||
label={folder.name}
|
||||
color={folder.color}
|
||||
isActive={target === folder.id}
|
||||
disabled={blocked.has(folder.id)}
|
||||
depth={depthById.get(folder.id) ?? 0}
|
||||
onPick={() => setTarget(folder.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Inline Create new folder; new folder becomes the move target. */}
|
||||
{onCreateFolder &&
|
||||
(() => {
|
||||
const trimmedName = newFolderName.trim();
|
||||
const handleCreate = async () => {
|
||||
if (trimmedName.length === 0) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await onCreateFolder(
|
||||
trimmedName,
|
||||
// ROOT becomes null parent.
|
||||
target === ROOT_FOLDER_ID ? null : target,
|
||||
);
|
||||
setTarget(created.id);
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(
|
||||
"filesPage.moveDialog.newFolderError",
|
||||
"Could not create folder. Try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
};
|
||||
return creatingFolder ? (
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<TextInput
|
||||
label={t(
|
||||
"filesPage.moveDialog.newFolderLabel",
|
||||
"New folder name",
|
||||
)}
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.currentTarget.value)}
|
||||
placeholder={t(
|
||||
"filesPage.moveDialog.newFolderPlaceholder",
|
||||
"Folder name",
|
||||
)}
|
||||
style={{ flex: 1 }}
|
||||
disabled={creating}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
loading={creating}
|
||||
disabled={trimmedName.length === 0}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("filesPage.moveDialog.newFolderCreate", "Create")}
|
||||
</Button>
|
||||
{/* X collapses the inline create row only. */}
|
||||
<Tooltip
|
||||
label={t("filesPage.moveDialog.newFolderCancel", "Discard")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
disabled={creating}
|
||||
aria-label={t(
|
||||
"filesPage.moveDialog.newFolderCancel",
|
||||
"Discard",
|
||||
)}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<CreateNewFolderIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
setCreatingFolder(true);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
styles={{ root: { alignSelf: "flex-start" } }}
|
||||
>
|
||||
{t(
|
||||
"filesPage.moveDialog.newFolderToggle",
|
||||
"Create new folder…",
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{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.moveDialog.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm(target);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(
|
||||
"filesPage.moveDialog.error",
|
||||
"Could not move. Try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveDialog.confirm", "Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface FolderPickProps {
|
||||
label: string;
|
||||
color?: string;
|
||||
isActive: boolean;
|
||||
disabled: boolean;
|
||||
depth: number;
|
||||
isRoot?: boolean;
|
||||
onPick: () => void;
|
||||
}
|
||||
|
||||
function FolderPick({
|
||||
label,
|
||||
color,
|
||||
isActive,
|
||||
disabled,
|
||||
depth,
|
||||
isRoot,
|
||||
onPick,
|
||||
}: FolderPickProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
all: "unset",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
|
||||
width: "100%",
|
||||
background: isActive ? "var(--hover-bg)" : "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
boxSizing: "border-box",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{isRoot ? (
|
||||
<HomeIcon fontSize="small" />
|
||||
) : isActive ? (
|
||||
<FolderOpenIcon fontSize="small" style={{ color }} />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" style={{ color }} />
|
||||
)}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId } from "@app/types/folder";
|
||||
|
||||
/**
|
||||
* Custom MIME used to flag drag operations originating inside the
|
||||
* file manager page. Using a custom type avoids clashing with native
|
||||
* file/text drags from the OS.
|
||||
*/
|
||||
export const FILES_PAGE_DRAG_TYPE = "application/x-stirling-files-page";
|
||||
|
||||
export type FilesPageDragPayload =
|
||||
| { kind: "files"; fileIds: FileId[] }
|
||||
| { kind: "folder"; folderId: FolderId };
|
||||
|
||||
export function serialiseFilesPageDragPayload(
|
||||
payload: FilesPageDragPayload,
|
||||
): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseFilesPageDragPayload(
|
||||
dataTransfer: DataTransfer,
|
||||
): FilesPageDragPayload | null {
|
||||
if (!dataTransfer.types.includes(FILES_PAGE_DRAG_TYPE)) return null;
|
||||
const raw = dataTransfer.getData(FILES_PAGE_DRAG_TYPE);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as FilesPageDragPayload;
|
||||
if (parsed.kind === "files" && Array.isArray(parsed.fileIds)) {
|
||||
return parsed;
|
||||
}
|
||||
if (parsed.kind === "folder" && typeof parsed.folderId === "string") {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Classifies where a stored file lives. The UI uses this to badge each file
|
||||
* (Local vs Cloud) and to drive the origin filter chip.
|
||||
*
|
||||
* - "local" - only in the browser's IndexedDB
|
||||
* - "cloud" - also exists on the server (uploaded), still owned by the user
|
||||
* - "shared-with-me" - opened from a share link / not owned by current user
|
||||
*/
|
||||
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
export type FileOrigin = "local" | "cloud" | "shared-with-me";
|
||||
|
||||
export const FILE_ORIGINS: FileOrigin[] = ["local", "cloud", "shared-with-me"];
|
||||
|
||||
export function getFileOrigin(file: StirlingFileStub): FileOrigin {
|
||||
if (file.remoteSharedViaLink || file.remoteOwnedByCurrentUser === false) {
|
||||
return "shared-with-me";
|
||||
}
|
||||
if (file.remoteStorageId) {
|
||||
return "cloud";
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Stores the route the user came from when they open files into the
|
||||
* workbench from My Files. Lets the workbench show a "Back to My Files"
|
||||
* affordance and return to the exact folder they were browsing.
|
||||
*
|
||||
* Persisted in sessionStorage so a hard reload keeps the return path
|
||||
* (matches user mental model - Cmd+R shouldn't lose the breadcrumb).
|
||||
*/
|
||||
|
||||
const SESSION_KEY = "stirling.filesPage.returnRoute";
|
||||
const SESSION_LABEL_KEY = "stirling.filesPage.returnLabel";
|
||||
|
||||
export interface FilesPageReturnRoute {
|
||||
route: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached snapshot so `useSyncExternalStore` returns a stable reference
|
||||
* across consecutive renders. The cache is invalidated whenever the
|
||||
* sessionStorage entry changes (via set/clear or storage event).
|
||||
*/
|
||||
let cachedSnapshot: FilesPageReturnRoute | null = null;
|
||||
let cachedSerialised = "";
|
||||
|
||||
function readFromStorage(): FilesPageReturnRoute | null {
|
||||
try {
|
||||
const route = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!route) return null;
|
||||
const label = sessionStorage.getItem(SESSION_LABEL_KEY) ?? undefined;
|
||||
return { route, label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSnapshot(): void {
|
||||
const next = readFromStorage();
|
||||
const serialised = next ? `${next.route}|${next.label ?? ""}` : "";
|
||||
if (serialised !== cachedSerialised) {
|
||||
cachedSnapshot = next;
|
||||
cachedSerialised = serialised;
|
||||
}
|
||||
}
|
||||
|
||||
export function setFilesPageReturnRoute(route: string, label?: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, route);
|
||||
if (label) sessionStorage.setItem(SESSION_LABEL_KEY, label);
|
||||
else sessionStorage.removeItem(SESSION_LABEL_KEY);
|
||||
refreshSnapshot();
|
||||
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFilesPageReturnRoute(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
sessionStorage.removeItem(SESSION_LABEL_KEY);
|
||||
refreshSnapshot();
|
||||
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getFilesPageReturnRoute(): FilesPageReturnRoute | null {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
/** Subscribe to changes (storage + same-tab CustomEvent). */
|
||||
export function subscribeFilesPageReturnRoute(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
const handler = () => {
|
||||
refreshSnapshot();
|
||||
listener();
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("stirling-filespage-return-changed", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener("stirling-filespage-return-changed", handler);
|
||||
};
|
||||
}
|
||||
|
||||
// Initial read on module load so the first useSyncExternalStore snapshot
|
||||
// is correct even before any setters fire.
|
||||
refreshSnapshot();
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Folder icon presets. Each is a single emoji that overlays the folder
|
||||
* thumbnail. Picked to cover the most common organisational uses of a
|
||||
* folder without resorting to a custom icon system.
|
||||
*
|
||||
* Kept small (≤24) so the picker is one glanceable row in the menu.
|
||||
*/
|
||||
|
||||
export interface FolderIconOption {
|
||||
id: string;
|
||||
glyph: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const FOLDER_ICONS: FolderIconOption[] = [
|
||||
{ id: "none", glyph: "", label: "No icon" },
|
||||
{ id: "star", glyph: "★", label: "Star" },
|
||||
{ id: "heart", glyph: "♥", label: "Heart" },
|
||||
{ id: "work", glyph: "💼", label: "Work" },
|
||||
{ id: "home", glyph: "🏠", label: "Home" },
|
||||
{ id: "tax", glyph: "💰", label: "Money" },
|
||||
{ id: "receipt", glyph: "🧾", label: "Receipt" },
|
||||
{ id: "contract", glyph: "📝", label: "Contract" },
|
||||
{ id: "id", glyph: "🪪", label: "ID" },
|
||||
{ id: "house", glyph: "🏡", label: "House" },
|
||||
{ id: "travel", glyph: "✈️", label: "Travel" },
|
||||
{ id: "photos", glyph: "🖼️", label: "Photos" },
|
||||
{ id: "music", glyph: "🎵", label: "Music" },
|
||||
{ id: "code", glyph: "💻", label: "Code" },
|
||||
{ id: "health", glyph: "🏥", label: "Health" },
|
||||
{ id: "school", glyph: "🎓", label: "School" },
|
||||
{ id: "warning", glyph: "⚠️", label: "Warning" },
|
||||
{ id: "archive", glyph: "📦", label: "Archive" },
|
||||
];
|
||||
|
||||
export function findFolderIcon(
|
||||
id: string | undefined,
|
||||
): FolderIconOption | null {
|
||||
if (!id) return null;
|
||||
return FOLDER_ICONS.find((i) => i.id === id) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
|
||||
const STORAGE_KEY = "stirling-folder-tree-width";
|
||||
export const MIN_WIDTH = 210;
|
||||
export const MAX_WIDTH = 480;
|
||||
const DEFAULT_WIDTH = 272;
|
||||
|
||||
const ROW_FONT =
|
||||
'14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
const COUNT_FONT =
|
||||
'12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
/** Per-row chrome: left padding + toggle + icon + name margin + count margin + right padding. */
|
||||
const ROW_CHROME = 14 + 16 + 18 + 8 + 12 + 8 + 14;
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
|
||||
function loadCanvas(): CanvasRenderingContext2D | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const canvas = document.createElement("canvas");
|
||||
return canvas.getContext("2d");
|
||||
}
|
||||
|
||||
function depthOf(
|
||||
folder: FolderRecord,
|
||||
byId: Map<string, FolderRecord>,
|
||||
): number {
|
||||
let depth = 0;
|
||||
let cursor: FolderRecord | undefined = folder;
|
||||
while (cursor && cursor.parentFolderId) {
|
||||
depth += 1;
|
||||
cursor = byId.get(cursor.parentFolderId as string);
|
||||
if (depth > 50) break;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** Width needed to fit the longest folder row, clamped to [MIN, MAX]. */
|
||||
export function computeAutoFitWidth(
|
||||
folders: FolderRecord[],
|
||||
rootLabel: string,
|
||||
): number {
|
||||
const ctx = loadCanvas();
|
||||
if (!ctx) return DEFAULT_WIDTH;
|
||||
const byId = new Map(folders.map((f) => [f.id as string, f]));
|
||||
let maxName = 0;
|
||||
let maxDepth = 0;
|
||||
ctx.font = ROW_FONT;
|
||||
const measure = (name: string) => Math.ceil(ctx.measureText(name).width);
|
||||
maxName = Math.max(maxName, measure(rootLabel));
|
||||
for (const f of folders) {
|
||||
const w = measure(f.name);
|
||||
const d = depthOf(f, byId);
|
||||
if (w > maxName) maxName = w;
|
||||
if (d > maxDepth) maxDepth = d;
|
||||
}
|
||||
ctx.font = COUNT_FONT;
|
||||
// 4 digits covers typical counts (9999).
|
||||
const countWidth = Math.ceil(ctx.measureText("9999").width);
|
||||
const width = ROW_CHROME + maxDepth * INDENT_PER_LEVEL + maxName + countWidth;
|
||||
return clamp(width);
|
||||
}
|
||||
|
||||
export function clamp(width: number): number {
|
||||
if (Number.isNaN(width)) return DEFAULT_WIDTH;
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
export function loadPersistedWidth(): number | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage?.getItem(STORAGE_KEY);
|
||||
if (raw == null) return null;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return clamp(parsed);
|
||||
}
|
||||
|
||||
export function savePersistedWidth(width: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage?.setItem(STORAGE_KEY, String(clamp(width)));
|
||||
} catch {
|
||||
// localStorage can throw in private mode or when over quota; ignore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* useDropTarget - stable hover-state tracking for drop targets.
|
||||
*
|
||||
* The default HTML5 drag-and-drop API fires `dragenter`/`dragleave` for every
|
||||
* child element the pointer crosses, which makes naive `setHover(true/false)`
|
||||
* implementations flicker. This hook tracks enter/leave with a counter so the
|
||||
* hover state only resets when the pointer truly leaves the bounding box, and
|
||||
* caches the last `dropEffect`/payload to avoid redundant state updates.
|
||||
*/
|
||||
|
||||
import {
|
||||
DragEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface UseDropTargetOptions {
|
||||
/**
|
||||
* MIME type of the drag payload to react to. dragover/drop events for
|
||||
* other payloads are ignored so external file drags still bubble up to
|
||||
* the page-level drop zone.
|
||||
*/
|
||||
dragType: string;
|
||||
/** Fired when the user releases over the target. */
|
||||
onDrop: (event: React.DragEvent<HTMLElement>) => void;
|
||||
/** Optional CSS effect - defaults to "move". */
|
||||
dropEffect?: DataTransfer["dropEffect"];
|
||||
/** Disable the target without unmounting. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DropTargetBinding {
|
||||
/** Bind to the element you want to act as a drop target. */
|
||||
handlers: {
|
||||
onDragEnter: DragEventHandler<HTMLElement>;
|
||||
onDragOver: DragEventHandler<HTMLElement>;
|
||||
onDragLeave: DragEventHandler<HTMLElement>;
|
||||
onDrop: DragEventHandler<HTMLElement>;
|
||||
};
|
||||
/** True while the pointer is over the element (or any of its children). */
|
||||
isOver: boolean;
|
||||
}
|
||||
|
||||
export function useDropTarget({
|
||||
dragType,
|
||||
onDrop,
|
||||
dropEffect = "move",
|
||||
disabled,
|
||||
}: UseDropTargetOptions): DropTargetBinding {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const counter = useRef(0);
|
||||
|
||||
// If the element gets unmounted mid-drag, reset state.
|
||||
useEffect(
|
||||
() => () => {
|
||||
counter.current = 0;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const accepts = useCallback(
|
||||
(e: React.DragEvent<HTMLElement>) =>
|
||||
e.dataTransfer.types.includes(dragType),
|
||||
[dragType],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback<DragEventHandler<HTMLElement>>(
|
||||
(e) => {
|
||||
if (disabled || !accepts(e)) return;
|
||||
e.preventDefault();
|
||||
counter.current += 1;
|
||||
if (!isOver) setIsOver(true);
|
||||
},
|
||||
[accepts, disabled, isOver],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback<DragEventHandler<HTMLElement>>(
|
||||
(e) => {
|
||||
if (disabled || !accepts(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = dropEffect;
|
||||
if (!isOver) setIsOver(true);
|
||||
},
|
||||
[accepts, disabled, dropEffect, isOver],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback<DragEventHandler<HTMLElement>>(
|
||||
(e) => {
|
||||
if (disabled || !accepts(e)) return;
|
||||
counter.current -= 1;
|
||||
if (counter.current <= 0) {
|
||||
counter.current = 0;
|
||||
setIsOver(false);
|
||||
}
|
||||
},
|
||||
[accepts, disabled],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback<DragEventHandler<HTMLElement>>(
|
||||
(e) => {
|
||||
if (disabled || !accepts(e)) return;
|
||||
e.preventDefault();
|
||||
counter.current = 0;
|
||||
setIsOver(false);
|
||||
onDrop(e);
|
||||
},
|
||||
[accepts, disabled, onDrop],
|
||||
);
|
||||
|
||||
return {
|
||||
handlers: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
isOver,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,9 @@ const PageEditorControls = lazy(
|
||||
() => import("@app/components/pageEditor/PageEditorControls"),
|
||||
);
|
||||
const Viewer = lazy(() => import("@app/components/viewer/Viewer"));
|
||||
const FileManagerView = lazy(
|
||||
() => import("@app/components/filesPage/FileManagerView"),
|
||||
);
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -99,6 +102,12 @@ export default function Workbench() {
|
||||
}
|
||||
}
|
||||
|
||||
// The "My Files" workbench is available regardless of whether files are
|
||||
// currently loaded into the workbench - it lives on top of the IDB store.
|
||||
if (currentView === "myFiles") {
|
||||
return <FileManagerView />;
|
||||
}
|
||||
|
||||
if (activeFiles.length === 0) {
|
||||
return <LandingPage />;
|
||||
}
|
||||
@@ -183,27 +192,31 @@ export default function Workbench() {
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
: { backgroundColor: "var(--bg-background)" }
|
||||
? // No background color in rainbow mode, but still pin min-width:0
|
||||
// so inner flex children (files-page toolbar, etc.) actually
|
||||
// shrink on narrow viewports.
|
||||
{ minWidth: 0 }
|
||||
: { backgroundColor: "var(--bg-background)", minWidth: 0 }
|
||||
}
|
||||
>
|
||||
{/* Workbench Bar - animates in/out based on file presence */}
|
||||
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
{currentView !== "myFiles" &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
@@ -213,6 +226,11 @@ export default function Workbench() {
|
||||
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
|
||||
style={{
|
||||
transition: "opacity 0.15s ease-in-out",
|
||||
// Force min-width:0 so flex children (notably the files page
|
||||
// toolbar with its 5 bulk-action buttons + 2 selects + view
|
||||
// toggle) can shrink below their intrinsic content size on
|
||||
// narrow viewports instead of overflowing horizontally.
|
||||
minWidth: 0,
|
||||
...(currentView === "pageEditor" && { height: 0 }),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -74,12 +74,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Handle custom events for backwards compatibility
|
||||
// Handle custom events for backwards compatibility.
|
||||
// Use replace when already on /settings/* so external tab-switches
|
||||
// don't pile up history entries that would break close-by-back.
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
navigate(`/settings/${detail.key}`);
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
navigate(`/settings/${detail.key}`, {
|
||||
replace: alreadyInSettings,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
@@ -112,10 +118,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
// Navigate back to home when closing modal
|
||||
navigate("/", { replace: true });
|
||||
// Pop back to whatever the user came from (files / viewer / tools).
|
||||
// location.key === "default" means /settings was the first entry in
|
||||
// this tab (deep link / refresh), so there's nothing to pop to;
|
||||
// fall back to home in that case.
|
||||
if (location.key === "default") {
|
||||
navigate("/", { replace: true });
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
onClose();
|
||||
}, [confirmIfDirty, navigate, onClose]);
|
||||
}, [confirmIfDirty, location.key, navigate, onClose]);
|
||||
|
||||
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
|
||||
const handleCloseSync = useCallback(() => {
|
||||
@@ -152,7 +165,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
if (!canProceed) return;
|
||||
|
||||
setActive(key);
|
||||
navigate(`/settings/${key}`);
|
||||
// First in-modal nav (when current path isn't `/settings/*` yet) must
|
||||
// PUSH so the originating page stays in history and close-by-back can
|
||||
// return to it. Subsequent tab switches REPLACE so they don't pile up
|
||||
// history entries that handleClose's navigate(-1) can't unwind.
|
||||
//
|
||||
// Read window.location.pathname directly (not the React hook's
|
||||
// location.pathname) so rapid successive clicks pick up the URL
|
||||
// change from the previous click immediately. The hook snapshot is
|
||||
// stale between render cycles - relying on it lets a second click
|
||||
// PUSH again before React re-renders, producing a history pile-up.
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
navigate(`/settings/${key}`, { replace: alreadyInSettings });
|
||||
},
|
||||
[confirmIfDirty, navigate],
|
||||
);
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
transition:
|
||||
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Animating width + min-width + max-width together can leave the flex layout
|
||||
stuck on the pre-animation size in some browsers. Snap instead and rely
|
||||
on the inner content fade for visual smoothness. */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -40,7 +39,7 @@
|
||||
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
|
||||
|
||||
.file-sidebar-header:hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.file-sidebar-menu-icon {
|
||||
@@ -49,6 +48,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Inherits font-size so swap-in icons render at 18px like the original. */
|
||||
.file-sidebar-menu-icon > svg {
|
||||
font-size: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */
|
||||
[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.file-sidebar-brand-text {
|
||||
height: 22px;
|
||||
width: auto;
|
||||
@@ -70,7 +81,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-search-row:not(.active):hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.file-sidebar-search-icon {
|
||||
@@ -105,7 +116,7 @@
|
||||
}
|
||||
|
||||
/* ---- Scrollable content ---- */
|
||||
/* This is a flex column — action rows are fixed, only the file list scrolls */
|
||||
/* This is a flex column - action rows are fixed, only the file list scrolls */
|
||||
.file-sidebar-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -140,13 +151,36 @@
|
||||
}
|
||||
|
||||
.file-sidebar-action-row:hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.file-sidebar-action-row.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.file-sidebar-action-row.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-action-icon {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* When the icon is a wrapper around an SVG (extraAction case), force
|
||||
* the SVG to inherit the wrapper's 18px sizing instead of MUI's default
|
||||
* 1.5rem so it matches the other rail icons. */
|
||||
.file-sidebar-action-icon > svg {
|
||||
font-size: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.file-sidebar-action-label {
|
||||
@@ -171,7 +205,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row.disabled {
|
||||
@@ -296,7 +330,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-section-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -376,7 +410,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-bar[role="button"]:focus-visible {
|
||||
|
||||
@@ -5,10 +5,10 @@ import React, {
|
||||
useEffect,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { Loader, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
@@ -41,11 +42,40 @@ export interface FileSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
/** Accessible name override for the toggle button. */
|
||||
toggleAriaLabel?: string;
|
||||
/** Icon override for the toggle button (e.g. back-arrow on /files). */
|
||||
toggleIcon?: React.ReactNode;
|
||||
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
|
||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Google Drive handler. */
|
||||
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Search row click (e.g. focus the /files search input). */
|
||||
onSearchClick?: () => void;
|
||||
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
|
||||
extraAction?: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
testId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function FileSidebar(
|
||||
{ collapsed = false, onToggleCollapse, onOpenSettings },
|
||||
{
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
onSearchClick,
|
||||
extraAction,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
@@ -53,12 +83,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const nativeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
// State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates.
|
||||
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
|
||||
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useAppConfig();
|
||||
const {
|
||||
isEnabled: isGoogleDriveEnabled,
|
||||
@@ -93,7 +123,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const [stubsLoaded, setStubsLoaded] = useState(false);
|
||||
|
||||
const refreshStubs = useCallback(async () => {
|
||||
// Leaf files from IDB — same source as the file selection modal.
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
const stubs = await indexedDB.loadLeafMetadata();
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
|
||||
@@ -138,11 +168,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
if (onSearchClick) {
|
||||
onSearchClick();
|
||||
return;
|
||||
}
|
||||
if (collapsed && onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
setSearchActive(true);
|
||||
}, [collapsed, onToggleCollapse]);
|
||||
}, [collapsed, onToggleCollapse, onSearchClick]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setSearchActive(false);
|
||||
@@ -159,11 +193,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const handleGoogleDriveClick = useCallback(async () => {
|
||||
if (!isGoogleDriveEnabled) return;
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
if (onPickGoogleDriveFiles) {
|
||||
await onPickGoogleDriveFiles(files);
|
||||
return;
|
||||
}
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
|
||||
}
|
||||
}, [
|
||||
isGoogleDriveEnabled,
|
||||
@@ -171,6 +208,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
addFiles,
|
||||
navActions,
|
||||
isMultiTool,
|
||||
onPickGoogleDriveFiles,
|
||||
]);
|
||||
|
||||
// Toggle file in/out of workbench
|
||||
@@ -195,7 +233,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}
|
||||
await fileActions.removeFiles([workbenchFileId], false);
|
||||
} else {
|
||||
// Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry.
|
||||
// Re-add by stub to preserve its ID - addFiles() would create a new UUID + IDB entry.
|
||||
const workbenchCount = state.files.ids.length;
|
||||
|
||||
if (workbenchCount > 0 && currentWorkbench === "viewer") {
|
||||
@@ -231,7 +269,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
],
|
||||
);
|
||||
|
||||
// Which file is currently open in the viewer — stable ID, never index-derived.
|
||||
// Which file is currently open in the viewer - stable ID, never index-derived.
|
||||
const viewedWorkbenchId =
|
||||
currentWorkbench === "viewer" ? activeFileId : null;
|
||||
|
||||
@@ -246,12 +284,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
);
|
||||
|
||||
if (isCurrentlyViewed) {
|
||||
// Closing the currently-viewed file — guard against unsaved changes.
|
||||
// Closing the currently-viewed file - guard against unsaved changes.
|
||||
navActions.setWorkbench("fileEditor");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching to a different file while viewer is open — guard against unsaved changes.
|
||||
// Switching to a different file while viewer is open - guard against unsaved changes.
|
||||
const performSwitch = async () => {
|
||||
const alreadyInWorkbench = state.files.ids.some(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
@@ -291,18 +329,23 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
|
||||
const handleNativeFilePick = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Per-tool validation happens downstream.
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(
|
||||
files.length === 1 ? "viewer" : "fileEditor",
|
||||
);
|
||||
if (onUploadFiles) {
|
||||
await onUploadFiles(files);
|
||||
} else {
|
||||
await addFiles(files);
|
||||
if (!isMultiTool) {
|
||||
navActions.setWorkbench(
|
||||
files.length === 1 ? "viewer" : "fileEditor",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles, navActions, isMultiTool],
|
||||
[addFiles, navActions, isMultiTool, onUploadFiles],
|
||||
);
|
||||
|
||||
const shouldHideGoogleDrive =
|
||||
@@ -321,29 +364,53 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
>
|
||||
<div className="file-sidebar-inner">
|
||||
{/* Header: hamburger + branding */}
|
||||
<div
|
||||
className="file-sidebar-header"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar")
|
||||
}
|
||||
<Tooltip
|
||||
label={toggleAriaLabel ?? t("fileSidebar.expand", "Expand sidebar")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<MenuIcon className="file-sidebar-menu-icon" />
|
||||
{!collapsed && (
|
||||
<Wordmark
|
||||
alt="Stirling PDF"
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="file-sidebar-header"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggleCollapse?.();
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
toggleAriaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
>
|
||||
{/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */}
|
||||
<span
|
||||
className="file-sidebar-menu-icon"
|
||||
data-toggle-flip-rtl={toggleIcon ? "true" : undefined}
|
||||
>
|
||||
{toggleIcon ?? <MenuIcon />}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<Wordmark
|
||||
alt="Stirling PDF"
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Search row */}
|
||||
{
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
@@ -385,70 +452,204 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</Tooltip>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Open from Computer + Google Drive */}
|
||||
{
|
||||
<>
|
||||
{/* Hidden native file input - kept outside the !collapsed gate so
|
||||
the "Open from computer" row below (always rendered) can fire
|
||||
it in either sidebar state without a silent no-op. */}
|
||||
<input
|
||||
ref={nativeFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
// No `accept` filter - this picker feeds the global workspace,
|
||||
// not a specific tool, so users may legitimately upload PNGs,
|
||||
// ZIPs, etc. for the convert/merge/extract tools to handle.
|
||||
style={{ display: "none" }}
|
||||
onChange={handleNativeFilePick}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
{/* Open from Computer + My Files + Google Drive */}
|
||||
{/* Tooltips only fire when collapsed - when expanded the visible
|
||||
text label below already identifies each row, so a tooltip
|
||||
would just flash a duplicate. Distinct icons (UploadFile for
|
||||
"Open from computer" vs FolderOpen for "My Files") so the
|
||||
collapsed rail isn't two identical folder icons either. */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
// `files-button` is the long-standing upload entry-point
|
||||
// testid: click + setInputFiles on `file-input` above. Tour
|
||||
// anchor lives here too - the tour now spotlights the native
|
||||
// picker shortcut rather than the old modal.
|
||||
data-testid="files-button"
|
||||
data-tour="files-button"
|
||||
onClick={() => {
|
||||
// "Open from computer" goes straight to the native OS file
|
||||
// picker. The full file manager (recent + drives + folders)
|
||||
// is reachable via "My Files" below.
|
||||
nativeFileInputRef.current?.click();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t(
|
||||
"fileSidebar.openFromComputer",
|
||||
"Open from computer",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
nativeFileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{extraAction && (
|
||||
<Tooltip
|
||||
label={extraAction.disabledTooltip ?? extraAction.label}
|
||||
position="right"
|
||||
withinPortal
|
||||
// Only force a wide multiline box when the long disabled
|
||||
// reason is shown; the short label fits one line.
|
||||
multiline={Boolean(
|
||||
extraAction.disabled && extraAction.disabledTooltip,
|
||||
)}
|
||||
w={
|
||||
extraAction.disabled && extraAction.disabledTooltip
|
||||
? 220
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
!collapsed &&
|
||||
!(extraAction.disabled && extraAction.disabledTooltip)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
data-testid="files-button"
|
||||
data-tour="files-button"
|
||||
className={`file-sidebar-action-row${extraAction.disabled ? " disabled" : ""}`}
|
||||
data-testid={extraAction.testId}
|
||||
onClick={() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
openFilesModal();
|
||||
if (extraAction.disabled) return;
|
||||
extraAction.onClick();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
|
||||
tabIndex={extraAction.disabled ? -1 : 0}
|
||||
aria-disabled={extraAction.disabled}
|
||||
aria-label={extraAction.label}
|
||||
onKeyDown={(e) => {
|
||||
if (extraAction.disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
extraAction.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
<span className="file-sidebar-action-icon">
|
||||
{extraAction.icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
{extraAction.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!shouldHideGoogleDrive && (
|
||||
<div
|
||||
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
|
||||
onClick={handleGoogleDriveClick}
|
||||
role="button"
|
||||
tabIndex={isGoogleDriveEnabled ? 0 : -1}
|
||||
aria-disabled={!isGoogleDriveEnabled}
|
||||
title={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
"fileSidebar.googleDriveDisabled",
|
||||
"Google Drive is not configured",
|
||||
)
|
||||
: t("fileSidebar.googleDrive", "Open from Google Drive")
|
||||
}
|
||||
>
|
||||
<div className="file-sidebar-cloud-icon-wrapper">
|
||||
<Tooltip
|
||||
label={t("fileSidebar.myFiles", "My Files")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
data-testid="my-files-button"
|
||||
onClick={() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
navigate("/files");
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("fileSidebar.myFiles", "My Files")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate("/files");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.myFiles", "My Files")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{!shouldHideGoogleDrive && (
|
||||
<Tooltip
|
||||
label={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
"fileSidebar.googleDriveDisabled",
|
||||
"Google Drive is not configured",
|
||||
)
|
||||
: t("fileSidebar.googleDrive", "Open from Google Drive")
|
||||
}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
|
||||
onClick={handleGoogleDriveClick}
|
||||
role="button"
|
||||
tabIndex={isGoogleDriveEnabled ? 0 : -1}
|
||||
aria-disabled={!isGoogleDriveEnabled}
|
||||
aria-label={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
"fileSidebar.googleDriveDisabled",
|
||||
"Google Drive is not configured",
|
||||
)
|
||||
: t("fileSidebar.googleDrive", "Open from Google Drive")
|
||||
}
|
||||
>
|
||||
<div className="file-sidebar-cloud-icon-wrapper">
|
||||
<GoogleDriveIcon
|
||||
className="file-sidebar-cloud-icon-gray"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
{isGoogleDriveEnabled && (
|
||||
<GoogleDriveIcon
|
||||
className="file-sidebar-cloud-icon-gray"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
colored
|
||||
className="file-sidebar-cloud-icon-color"
|
||||
/>
|
||||
{isGoogleDriveEnabled && (
|
||||
<GoogleDriveIcon
|
||||
colored
|
||||
className="file-sidebar-cloud-icon-color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.googleDrive", "Google Drive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.googleDrive", "Google Drive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Files section - always visible when expanded */}
|
||||
{!collapsed && (
|
||||
@@ -459,12 +660,13 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</span>
|
||||
<button
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
onClick={() => openFilesModal()}
|
||||
onClick={() => navigate("/files")}
|
||||
title={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Open file manager",
|
||||
"Browse all files & folders",
|
||||
)}
|
||||
type="button"
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
</button>
|
||||
@@ -476,14 +678,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "1rem" }} />
|
||||
</button>
|
||||
<input
|
||||
ref={nativeFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleNativeFilePick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!stubsLoaded ? (
|
||||
@@ -497,14 +691,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
const isInWorkbench = !!workbenchFileId;
|
||||
// Both active and viewed-in-viewer are ID-based — never index-based.
|
||||
// Both active and viewed-in-viewer are ID-based - never index-based.
|
||||
const isViewedInViewer = !!(
|
||||
viewedWorkbenchId &&
|
||||
viewedWorkbenchId === (stub.id as string)
|
||||
);
|
||||
const isActive = isViewedInViewer;
|
||||
// In-memory thumbnail may be fresher than the IndexedDB stub.
|
||||
// Encrypted files never get a raster thumbnail — use undefined
|
||||
// Encrypted files never get a raster thumbnail - use undefined
|
||||
// so the sidebar icon is shown instead of a stale canvas thumbnail.
|
||||
const isEncryptedFile =
|
||||
stub.processedFile?.isEncrypted === true;
|
||||
@@ -548,44 +742,53 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: undefined
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div className="file-sidebar-bottom-avatar" title={displayName}>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: displayName
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-bottom-avatar"
|
||||
aria-label={displayName}
|
||||
>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { isFilesModalOpen } = useFilesModalContext();
|
||||
const {
|
||||
handleReaderToggle,
|
||||
handleToolSelect,
|
||||
@@ -131,7 +131,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
).length;
|
||||
setPendingSignCount(pending);
|
||||
} catch {
|
||||
/* silent — avoid noisy background error toasts */
|
||||
/* silent - avoid noisy background error toasts */
|
||||
}
|
||||
};
|
||||
fetchCount();
|
||||
@@ -264,7 +264,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
return `${normalized}/share/`;
|
||||
}
|
||||
} catch {
|
||||
// invalid URL — fall through to default
|
||||
// invalid URL - fall through to default
|
||||
}
|
||||
}
|
||||
return absoluteWithBasePath("/share/");
|
||||
@@ -592,7 +592,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
|
||||
|
||||
const handleFilesButtonClick = () => {
|
||||
openFilesModal();
|
||||
navigate("/files");
|
||||
};
|
||||
|
||||
// Helper function to render navigation buttons with URL support
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
background-color: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,20 @@
|
||||
color: var(--mantine-color-blue-6, #3b82f6);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-interactive, #4a90e2) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--accent-interactive, #4a90e2);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn svg {
|
||||
font-size: 16px !important;
|
||||
width: 16px;
|
||||
@@ -105,7 +119,7 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Shared action icon style — applies to both center and global buttons */
|
||||
/* Shared action icon style - applies to both center and global buttons */
|
||||
.workbench-bar-action-icon {
|
||||
color: var(--text-secondary) !important;
|
||||
width: 28px !important;
|
||||
@@ -121,7 +135,7 @@
|
||||
|
||||
.workbench-bar-action-icon:hover {
|
||||
color: var(--text-primary) !important;
|
||||
background-color: var(--bg-hover) !important;
|
||||
background-color: var(--hover-bg) !important;
|
||||
}
|
||||
|
||||
.workbench-bar-action-icon[data-variant="filled"] {
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import {
|
||||
clearFilesPageReturnRoute,
|
||||
getFilesPageReturnRoute,
|
||||
subscribeFilesPageReturnRoute,
|
||||
} from "@app/components/filesPage/filesPageReturnRoute";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
useFileState,
|
||||
@@ -66,6 +79,18 @@ export default function WorkbenchBar({
|
||||
hasFiles,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const returnRoute = useSyncExternalStore(
|
||||
subscribeFilesPageReturnRoute,
|
||||
getFilesPageReturnRoute,
|
||||
() => null,
|
||||
);
|
||||
const handleBackToFiles = useCallback(() => {
|
||||
if (!returnRoute) return;
|
||||
const target = returnRoute.route;
|
||||
clearFilesPageReturnRoute();
|
||||
navigate(target);
|
||||
}, [returnRoute, navigate]);
|
||||
const { buttons, actions, allButtonsDisabled } = useWorkbenchBar();
|
||||
const {
|
||||
pageEditorFunctions,
|
||||
@@ -356,8 +381,36 @@ export default function WorkbenchBar({
|
||||
data-wrapped="true"
|
||||
data-tour="workbench-bar"
|
||||
>
|
||||
{/* Left: View switcher */}
|
||||
{/* Left: optional "Back to My Files" + view switcher */}
|
||||
<div className="workbench-bar-views" data-tour="view-switcher">
|
||||
{returnRoute && hasFiles && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="workbench-bar-view-btn workbench-bar-back-btn"
|
||||
onClick={handleBackToFiles}
|
||||
aria-label={t(
|
||||
returnRoute.label
|
||||
? "filesPage.backToFolder"
|
||||
: "filesPage.backToMyFiles",
|
||||
returnRoute.label
|
||||
? `Back to ${returnRoute.label}`
|
||||
: "Back to My Files",
|
||||
{ folder: returnRoute.label ?? "" },
|
||||
)}
|
||||
>
|
||||
<ArrowBackIcon style={{ fontSize: "1.1rem" }} />
|
||||
<span className="workbench-bar-view-label">
|
||||
{returnRoute.label
|
||||
? t("filesPage.backToFolder", "Back to {{folder}}", {
|
||||
folder: returnRoute.label,
|
||||
})
|
||||
: t("filesPage.backToMyFiles", "Back to My Files")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="workbench-bar-divider" />
|
||||
</>
|
||||
)}
|
||||
{hasFiles &&
|
||||
viewOptions.map((opt) => (
|
||||
<button
|
||||
@@ -373,7 +426,7 @@ export default function WorkbenchBar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tool buttons — second row, only rendered when buttons exist */}
|
||||
{/* Tool buttons - second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
@@ -395,7 +448,7 @@ export default function WorkbenchBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Global buttons — export group left, close anchored right */}
|
||||
{/* Right: Global buttons - export group left, close anchored right */}
|
||||
<div className="workbench-bar-globals">
|
||||
{/* Print */}
|
||||
{currentView === "viewer" &&
|
||||
|
||||
@@ -241,6 +241,8 @@ function FileContextInner({
|
||||
insertAfterPageId?: string;
|
||||
selectFiles?: boolean;
|
||||
skipAutoUnzip?: boolean;
|
||||
/** Persist to IDB without dispatching to workspace state. */
|
||||
skipWorkspaceDispatch?: boolean;
|
||||
},
|
||||
): Promise<StirlingFile[]> => {
|
||||
const stirlingFiles = await addFiles(
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
/** Shared state for the My Files view. */
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileId } from "@app/types/file";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
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 { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/** View-toggle modes; tuple keeps the union and iterator in sync. */
|
||||
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
|
||||
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
|
||||
export type FilesPageSortMode =
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
| "modified-desc"
|
||||
| "modified-asc"
|
||||
| "size-desc"
|
||||
| "size-asc";
|
||||
export type FilesPageOriginFilter =
|
||||
| "all"
|
||||
| "local"
|
||||
| "cloud"
|
||||
| "shared-with-me";
|
||||
|
||||
/** all|local|cloud|recent|shared filter presets. */
|
||||
export type FilesPageTab =
|
||||
| "all"
|
||||
| "local"
|
||||
| "cloud"
|
||||
| "recent"
|
||||
| "shared"
|
||||
| "sharedByMe"
|
||||
| "imSharing";
|
||||
|
||||
export interface FolderNameDialogState {
|
||||
mode: "new" | "rename" | null;
|
||||
parentId?: FolderId | null;
|
||||
folder?: FolderRecord;
|
||||
}
|
||||
|
||||
export interface MoveDialogState {
|
||||
open: boolean;
|
||||
fileIds?: FileId[];
|
||||
folderId?: FolderId;
|
||||
initial: FolderId | null;
|
||||
}
|
||||
|
||||
interface FilesPageContextValue {
|
||||
// Cached files (leaf-only)
|
||||
allFiles: StirlingFileStub[];
|
||||
fileMap: Map<FileId, StirlingFileStub>;
|
||||
fileCountsByFolder: Map<FolderId | null, number>;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
// Selection
|
||||
selectedFileIds: Set<FileId>;
|
||||
setSelectedFileIds: React.Dispatch<React.SetStateAction<Set<FileId>>>;
|
||||
clearSelection: () => void;
|
||||
|
||||
// View + sort + search + filters
|
||||
viewMode: FilesPageViewMode;
|
||||
setViewMode: (mode: FilesPageViewMode) => void;
|
||||
sortMode: FilesPageSortMode;
|
||||
setSortMode: (mode: FilesPageSortMode) => void;
|
||||
search: string;
|
||||
setSearch: (value: string) => void;
|
||||
originFilter: FilesPageOriginFilter;
|
||||
setOriginFilter: (filter: FilesPageOriginFilter) => void;
|
||||
/** Selected file extensions (uppercased, e.g. ["PDF", "DOCX"]).
|
||||
* Empty array = no type filter applied. */
|
||||
typeFilter: string[];
|
||||
setTypeFilter: (next: string[]) => void;
|
||||
|
||||
/** Active filter-tab. Drives which files appear and which UI affordances enable. */
|
||||
currentTab: FilesPageTab;
|
||||
setCurrentTab: (tab: FilesPageTab) => void;
|
||||
|
||||
// Dialog state
|
||||
folderNameDialog: FolderNameDialogState;
|
||||
openNewFolderDialog: (parentId?: FolderId | null) => void;
|
||||
openRenameFolderDialog: (folder: FolderRecord) => void;
|
||||
closeFolderNameDialog: () => void;
|
||||
submitFolderName: (name: string) => Promise<void>;
|
||||
|
||||
moveDialog: MoveDialogState;
|
||||
promptMoveFiles: (fileIds: FileId[]) => void;
|
||||
closeMoveDialog: () => void;
|
||||
|
||||
// Action helpers
|
||||
moveFilesTo: (fileIds: FileId[], folderId: FolderId | null) => Promise<void>;
|
||||
moveFolderTo: (
|
||||
folderId: FolderId,
|
||||
newParentId: FolderId | null,
|
||||
) => Promise<void>;
|
||||
removeFiles: (fileIds: FileId[]) => Promise<void>;
|
||||
/** Open the confirmation dialog; consumer renders DeleteFolderDialog. */
|
||||
promptDeleteFolder: (folder: FolderRecord) => void;
|
||||
/** Confirmed delete; pass deleteContents=true to also remove files inside. */
|
||||
deleteFolder: (
|
||||
folder: FolderRecord,
|
||||
deleteContents: boolean,
|
||||
) => Promise<void>;
|
||||
deleteFolderDialog: {
|
||||
folder: FolderRecord | null;
|
||||
fileCount: number;
|
||||
};
|
||||
closeDeleteFolderDialog: () => void;
|
||||
setFolderAppearance: (
|
||||
folderId: FolderId,
|
||||
appearance: { color?: string; icon?: string | null },
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const FilesPageContext = createContext<FilesPageContextValue | null>(null);
|
||||
|
||||
export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const indexedDB = useIndexedDB();
|
||||
const folders = useFolders();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
|
||||
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Generation counter to drop stale reconcile results when a second refresh
|
||||
// overlaps the first. Mirrors the pattern FolderContext.pullFromServer uses
|
||||
// via pullInFlight, but kept as a counter so we can also discard results
|
||||
// from clearly-out-of-date calls instead of just serializing them.
|
||||
const refreshGenRef = useRef(0);
|
||||
|
||||
// Narrow dep so refresh isn't recreated on every folders field change.
|
||||
const setFoldersError = folders.setError;
|
||||
const storageEnabled = appConfig?.storageEnabled === true;
|
||||
const shareLinksEnabled = appConfig?.storageShareLinksEnabled === true;
|
||||
const refresh = useCallback(async () => {
|
||||
const gen = ++refreshGenRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const localStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
// Bail if a newer refresh started while IDB was reading.
|
||||
if (gen !== refreshGenRef.current) return;
|
||||
const localLeaf = localStubs.filter((s) => s.isLeaf !== false);
|
||||
// Render the cache immediately while the server fetch is in flight.
|
||||
setAllFiles(localLeaf);
|
||||
const merged = await reconcileServerFiles(localLeaf, {
|
||||
storageEnabled,
|
||||
shareLinksEnabled,
|
||||
});
|
||||
// Drop the merged result if a newer refresh has already started -
|
||||
// otherwise its stale snapshot will clobber the newer one's state.
|
||||
if (gen !== refreshGenRef.current) return;
|
||||
setAllFiles(merged);
|
||||
} catch (err) {
|
||||
if (gen !== refreshGenRef.current) return;
|
||||
console.error("[FilesPageContext] refresh failed", err);
|
||||
setFoldersError(
|
||||
err instanceof Error ? err.message : "Failed to load files",
|
||||
);
|
||||
} finally {
|
||||
// Only the latest refresh should clear the loading state.
|
||||
if (gen === refreshGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, indexedDB.revision]);
|
||||
|
||||
const fileMap = useMemo(() => {
|
||||
const map = new Map<FileId, StirlingFileStub>();
|
||||
for (const f of allFiles) map.set(f.id, f);
|
||||
return map;
|
||||
}, [allFiles]);
|
||||
|
||||
const fileCountsByFolder = useMemo(() => {
|
||||
const map = new Map<FolderId | null, number>();
|
||||
map.set(ROOT_FOLDER_ID, 0);
|
||||
for (const f of folders.folders) map.set(f.id, 0);
|
||||
for (const file of allFiles) {
|
||||
const fid = file.folderId ?? null;
|
||||
map.set(fid, (map.get(fid) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [allFiles, folders.folders]);
|
||||
|
||||
// Selection ---------------------------------------------------------------
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<FileId>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const clearSelection = useCallback(() => setSelectedFileIds(new Set()), []);
|
||||
|
||||
// Clear selection when folder changes.
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
}, [folders.currentFolderId, clearSelection]);
|
||||
|
||||
// View + sort + search + filters ----------------------------------------
|
||||
const [viewMode, setViewMode] = useState<FilesPageViewMode>("grid");
|
||||
const [sortMode, setSortMode] = useState<FilesPageSortMode>("modified-desc");
|
||||
const [search, setSearch] = useState("");
|
||||
const [originFilter, setOriginFilter] =
|
||||
useState<FilesPageOriginFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string[]>([]);
|
||||
const [currentTab, setCurrentTab] = useState<FilesPageTab>("all");
|
||||
|
||||
// Dialog: folder name -----------------------------------------------------
|
||||
const [folderNameDialog, setFolderNameDialog] =
|
||||
useState<FolderNameDialogState>({ mode: null });
|
||||
|
||||
const openNewFolderDialog = useCallback(
|
||||
(parentId: FolderId | null = folders.currentFolderId) => {
|
||||
setFolderNameDialog({ mode: "new", parentId });
|
||||
},
|
||||
[folders.currentFolderId],
|
||||
);
|
||||
|
||||
const openRenameFolderDialog = useCallback((folder: FolderRecord) => {
|
||||
setFolderNameDialog({ mode: "rename", folder });
|
||||
}, []);
|
||||
|
||||
const closeFolderNameDialog = useCallback(() => {
|
||||
setFolderNameDialog({ mode: null });
|
||||
}, []);
|
||||
|
||||
const submitFolderName = useCallback(
|
||||
async (name: string) => {
|
||||
if (folderNameDialog.mode === "new") {
|
||||
await folders.createFolder(
|
||||
name,
|
||||
folderNameDialog.parentId ?? folders.currentFolderId,
|
||||
);
|
||||
} else if (
|
||||
folderNameDialog.mode === "rename" &&
|
||||
folderNameDialog.folder
|
||||
) {
|
||||
await folders.renameFolder(folderNameDialog.folder.id, name);
|
||||
}
|
||||
},
|
||||
[folderNameDialog, folders],
|
||||
);
|
||||
|
||||
// Dialog: move ------------------------------------------------------------
|
||||
const [moveDialog, setMoveDialog] = useState<MoveDialogState>({
|
||||
open: false,
|
||||
initial: ROOT_FOLDER_ID,
|
||||
});
|
||||
|
||||
const promptMoveFiles = useCallback(
|
||||
(fileIds: FileId[]) => {
|
||||
setMoveDialog({ open: true, fileIds, initial: folders.currentFolderId });
|
||||
},
|
||||
[folders.currentFolderId],
|
||||
);
|
||||
|
||||
const closeMoveDialog = useCallback(() => {
|
||||
setMoveDialog((m) => ({ ...m, open: false }));
|
||||
}, []);
|
||||
|
||||
// Action helpers ----------------------------------------------------------
|
||||
|
||||
/** Cloud files move server-first; local files auto-upload then move. */
|
||||
const moveFilesTo = useCallback(
|
||||
async (fileIds: FileId[], folderId: FolderId | null) => {
|
||||
if (fileIds.length === 0) return;
|
||||
const stubs = fileIds
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
||||
const localOnly = stubs.filter((s) => s.remoteStorageId == null);
|
||||
// Cloud list is mutated below with newly-promoted local files.
|
||||
const cloudFiles = stubs.filter((s) => s.remoteStorageId != null);
|
||||
|
||||
if (folderId !== null && localOnly.length > 0) {
|
||||
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
|
||||
try {
|
||||
for (const stub of localOnly) {
|
||||
const rootId = (stub.originalFileId || stub.id) as FileId;
|
||||
const { remoteId, updatedAt, chain } =
|
||||
await uploadHistoryChain(rootId);
|
||||
for (const chainStub of chain) {
|
||||
fileActions.updateStirlingFileStub(chainStub.id, {
|
||||
remoteStorageId: remoteId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
await fileStorage.updateFileMetadata(chainStub.id, {
|
||||
remoteStorageId: remoteId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
}
|
||||
// Promoted file joins the bulk-move round.
|
||||
cloudFiles.push({
|
||||
...stub,
|
||||
remoteStorageId: remoteId,
|
||||
remoteStorageUpdatedAt: updatedAt,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
remoteSharedViaLink: false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
folders.setError(
|
||||
err instanceof Error
|
||||
? `Could not save files to server: ${err.message}`
|
||||
: "Could not save files to server.",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudFiles.length > 0) {
|
||||
try {
|
||||
const remoteIds = cloudFiles
|
||||
.map((s) => s.remoteStorageId!)
|
||||
.filter((id): id is number => typeof id === "number");
|
||||
const result = await folderSyncService.bulkMoveFiles(
|
||||
remoteIds,
|
||||
folderId,
|
||||
);
|
||||
if (result.skippedFileIds.length > 0) {
|
||||
folders.setError(
|
||||
t(
|
||||
"filesPage.moveSkippedRemote",
|
||||
"{{count}} file(s) couldn't be moved on the server (no permission or already deleted).",
|
||||
{ count: result.skippedFileIds.length },
|
||||
),
|
||||
);
|
||||
}
|
||||
const movedRemoteSet = new Set(result.movedFileIds);
|
||||
const idsToCacheMove = cloudFiles
|
||||
.filter((s) => movedRemoteSet.has(s.remoteStorageId!))
|
||||
.map((s) => s.id);
|
||||
if (idsToCacheMove.length > 0) {
|
||||
await indexedDB.moveFilesToFolder(idsToCacheMove, folderId);
|
||||
}
|
||||
} catch (err) {
|
||||
folders.setError(
|
||||
err instanceof Error
|
||||
? `Could not move files: ${err.message}`
|
||||
: "Could not move files.",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Local files moving to ROOT need no cloud write.
|
||||
await refresh();
|
||||
},
|
||||
[indexedDB, refresh, fileMap, folders, t, fileActions],
|
||||
);
|
||||
|
||||
const moveFolderTo = useCallback(
|
||||
async (folderId: FolderId, newParentId: FolderId | null) => {
|
||||
// Client-side cycle guard.
|
||||
if (newParentId !== null && folders.isDescendant(newParentId, folderId)) {
|
||||
folders.setError(
|
||||
t(
|
||||
"filesPage.cycleBlocked",
|
||||
"Can't move a folder into one of its own subfolders.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await folders.moveFolder(folderId, newParentId);
|
||||
},
|
||||
[folders, 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;
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[fileActions, refresh, t],
|
||||
);
|
||||
|
||||
const setFolderAppearance = useCallback(
|
||||
async (
|
||||
folderId: FolderId,
|
||||
appearance: { color?: string; icon?: string | null },
|
||||
) => {
|
||||
await folders.updateFolderAppearance(folderId, appearance);
|
||||
},
|
||||
[folders],
|
||||
);
|
||||
|
||||
const [deleteFolderDialog, setDeleteFolderDialog] = useState<{
|
||||
folder: FolderRecord | null;
|
||||
fileCount: number;
|
||||
}>({ folder: null, fileCount: 0 });
|
||||
const closeDeleteFolderDialog = useCallback(
|
||||
() => setDeleteFolderDialog({ folder: null, fileCount: 0 }),
|
||||
[],
|
||||
);
|
||||
|
||||
const filesInSubtree = useCallback(
|
||||
(folderId: FolderId): FileId[] => {
|
||||
const subtreeIds = new Set<FolderId>([folderId]);
|
||||
const stack: FolderId[] = [folderId];
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
for (const childId of folders.getChildFolderIds(cur)) {
|
||||
if (subtreeIds.has(childId)) continue;
|
||||
subtreeIds.add(childId);
|
||||
stack.push(childId);
|
||||
}
|
||||
}
|
||||
return allFiles
|
||||
.filter((f) => {
|
||||
const fid = f.folderId ?? null;
|
||||
return fid !== null && subtreeIds.has(fid);
|
||||
})
|
||||
.map((f) => f.id);
|
||||
},
|
||||
[allFiles, folders],
|
||||
);
|
||||
|
||||
const promptDeleteFolder = useCallback(
|
||||
(folder: FolderRecord) => {
|
||||
const fileCount = filesInSubtree(folder.id).length;
|
||||
setDeleteFolderDialog({ folder, fileCount });
|
||||
},
|
||||
[filesInSubtree],
|
||||
);
|
||||
|
||||
const deleteFolder = useCallback(
|
||||
async (folder: FolderRecord, deleteContents: boolean) => {
|
||||
if (deleteContents) {
|
||||
const fileIds = filesInSubtree(folder.id);
|
||||
if (fileIds.length > 0) {
|
||||
await fileActions.removeFiles(fileIds, true);
|
||||
}
|
||||
}
|
||||
await folders.deleteFolder(folder.id);
|
||||
await refresh();
|
||||
},
|
||||
[fileActions, filesInSubtree, folders, refresh],
|
||||
);
|
||||
|
||||
// Memoise to avoid re-rendering every FileCard on unrelated state churn.
|
||||
const value = useMemo<FilesPageContextValue>(
|
||||
() => ({
|
||||
allFiles,
|
||||
fileMap,
|
||||
fileCountsByFolder,
|
||||
loading,
|
||||
refresh,
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
clearSelection,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
sortMode,
|
||||
setSortMode,
|
||||
search,
|
||||
setSearch,
|
||||
originFilter,
|
||||
setOriginFilter,
|
||||
typeFilter,
|
||||
setTypeFilter,
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
folderNameDialog,
|
||||
openNewFolderDialog,
|
||||
openRenameFolderDialog,
|
||||
closeFolderNameDialog,
|
||||
submitFolderName,
|
||||
moveDialog,
|
||||
promptMoveFiles,
|
||||
closeMoveDialog,
|
||||
moveFilesTo,
|
||||
moveFolderTo,
|
||||
removeFiles,
|
||||
promptDeleteFolder,
|
||||
deleteFolder,
|
||||
deleteFolderDialog,
|
||||
closeDeleteFolderDialog,
|
||||
setFolderAppearance,
|
||||
}),
|
||||
[
|
||||
allFiles,
|
||||
fileMap,
|
||||
fileCountsByFolder,
|
||||
loading,
|
||||
refresh,
|
||||
selectedFileIds,
|
||||
clearSelection,
|
||||
viewMode,
|
||||
sortMode,
|
||||
search,
|
||||
originFilter,
|
||||
typeFilter,
|
||||
currentTab,
|
||||
folderNameDialog,
|
||||
openNewFolderDialog,
|
||||
openRenameFolderDialog,
|
||||
closeFolderNameDialog,
|
||||
submitFolderName,
|
||||
moveDialog,
|
||||
promptMoveFiles,
|
||||
closeMoveDialog,
|
||||
moveFilesTo,
|
||||
moveFolderTo,
|
||||
removeFiles,
|
||||
promptDeleteFolder,
|
||||
deleteFolder,
|
||||
deleteFolderDialog,
|
||||
closeDeleteFolderDialog,
|
||||
setFolderAppearance,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<FilesPageContext.Provider value={value}>
|
||||
{children}
|
||||
</FilesPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilesPage(): FilesPageContextValue {
|
||||
const ctx = useContext(FilesPageContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useFilesPage must be used within a FilesPageProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
|
||||
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
|
||||
|
||||
/**
|
||||
* Regression test for the sync-banner 4xx gating fix in commit c38b646c5.
|
||||
* Pre-fix, `pullFromServer.catch` unconditionally called `setError(...)`,
|
||||
* surfacing a "Folder sync failed: ..." banner for 401/403 responses
|
||||
* (storage disabled, user not signed in) - noise the user can't act on
|
||||
* from inside the file manager.
|
||||
*
|
||||
* Post-fix, the banner only appears when `status === undefined || status >= 500`
|
||||
* (server-side outage or genuine network failure - both retryable).
|
||||
*
|
||||
* The gate's `status` extraction relies on axios's error shape
|
||||
* (`err.response.status`). A future HTTP-client swap (e.g. to fetch where
|
||||
* the shape is `err.status`) would silently make `status` always undefined
|
||||
* and re-enable the noisy banner for every 4xx - exactly the regression
|
||||
* this test pins down.
|
||||
*/
|
||||
|
||||
// Mock the services FolderContext depends on. The real implementations
|
||||
// require a running backend (folderSyncService) and a populated IDB
|
||||
// (folderStorage). Both are out of scope for testing the error gate.
|
||||
const mockList = vi.fn();
|
||||
vi.mock("@app/services/folderSyncService", () => ({
|
||||
folderSyncService: {
|
||||
list: () => mockList(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/folderStorage", () => ({
|
||||
folderStorage: {
|
||||
getAllFolders: vi.fn().mockResolvedValue([]),
|
||||
replaceAll: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({
|
||||
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
// FolderProvider short-circuits pullFromServer when AppConfig says storage
|
||||
// is off. These tests are specifically about what happens when the pull
|
||||
// DOES fire and rejects, so we mock `storageEnabled = true`.
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: () => ({
|
||||
config: { storageEnabled: true, storageSharingEnabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Probe consumer that surfaces the bits of `useFolders()` this test cares
|
||||
* about. Rendered as text nodes so RTL queries can wait on them.
|
||||
*/
|
||||
function Probe() {
|
||||
const { error, serverReachable } = useFolders();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="error">{error ?? "<null>"}</div>
|
||||
<div data-testid="reachable">{String(serverReachable)}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an axios-shape rejection. `pullFromServer` reads
|
||||
* `err.response.status` to classify.
|
||||
*/
|
||||
function axiosError(status: number, message = "rejected"): Error {
|
||||
const err = new Error(message) as Error & {
|
||||
response: { status: number; data: unknown };
|
||||
};
|
||||
err.response = { status, data: { message } };
|
||||
return err;
|
||||
}
|
||||
|
||||
async function renderAndWaitForPull(): Promise<void> {
|
||||
render(
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>,
|
||||
);
|
||||
// The pull is fired from a mount effect; wait until it has resolved by
|
||||
// observing that `mockList` was called at least once and a tick has
|
||||
// elapsed for the resulting setState to flush.
|
||||
await waitFor(() => expect(mockList).toHaveBeenCalled());
|
||||
// Flush microtasks so the catch-block's setState lands.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("FolderContext sync-banner gating", () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset();
|
||||
});
|
||||
|
||||
test("401 (unauthorized) does NOT surface a banner", async () => {
|
||||
mockList.mockRejectedValue(axiosError(401, "unauthorized"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
// Buttons still need to be disabled - this is intentional.
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("403 (storage disabled) does NOT surface a banner", async () => {
|
||||
mockList.mockRejectedValue(axiosError(403, "Storage is disabled"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("500 (server error) DOES surface a banner", async () => {
|
||||
mockList.mockRejectedValue(axiosError(500, "internal"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toContain(
|
||||
"Folder sync failed",
|
||||
);
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("network error (no response) DOES surface a banner", async () => {
|
||||
// axios on a network failure rejects with an Error that has NO
|
||||
// `.response` property - that's the "status === undefined" branch
|
||||
// the gate explicitly covers.
|
||||
mockList.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toContain(
|
||||
"Folder sync failed",
|
||||
);
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("404 (endpoint missing, core-only build) does NOT surface a banner", async () => {
|
||||
// Separate code path from the 4xx gate - 404 specifically means
|
||||
// the storage backend isn't deployed; it's a config signal, not a
|
||||
// failure to act on.
|
||||
mockList.mockRejectedValue(axiosError(404, "not found"));
|
||||
await renderAndWaitForPull();
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* FolderContext - state management for the cloud folder hierarchy.
|
||||
*
|
||||
* The server is the source of truth. This context maintains an in-memory copy
|
||||
* (sourced from a small IDB read-cache for instant first paint), and every
|
||||
* mutation goes server-first via `folderSyncService`; on success the local
|
||||
* cache is updated and a revision tick fires so consumers re-render.
|
||||
*
|
||||
* The local IDB cache only exists so:
|
||||
* 1. The tree paints immediately on mount without waiting for the API.
|
||||
* 2. We can show a read-only tree when the server is unreachable.
|
||||
*
|
||||
* Cycle detection, ownership checks, and the per-user folder cap all live
|
||||
* on the server. The client just surfaces 400/409 errors via the existing
|
||||
* dialog Alert pattern.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { folderStorage } from "@app/services/folderStorage";
|
||||
import { folderSyncService } from "@app/services/folderSyncService";
|
||||
import {
|
||||
FolderBreadcrumbEntry,
|
||||
FolderId,
|
||||
FolderRecord,
|
||||
FolderTreeNode,
|
||||
ROOT_FOLDER_ID,
|
||||
createFolderId,
|
||||
pickFolderColor,
|
||||
} from "@app/types/folder";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface FolderContextValue {
|
||||
folders: FolderRecord[];
|
||||
foldersById: Map<FolderId, FolderRecord>;
|
||||
tree: FolderTreeNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Push a user-visible error string to the banner; null clears it. */
|
||||
setError: (msg: string | null) => void;
|
||||
|
||||
/**
|
||||
* Whether the most recent folder-API round-trip succeeded. Used to gate
|
||||
* folder mutation controls - when false, "New folder", rename, move,
|
||||
* delete, and appearance picker should be disabled.
|
||||
*
|
||||
* Initial value is `false` (fail-closed). Consumers will see a brief
|
||||
* window of disabled controls during the first pullFromServer call;
|
||||
* starting `true` would cause the inverse flash (enabled controls that
|
||||
* the user can click before the first response arrives, then a banner
|
||||
* after they submit the dialog), which is the worse failure mode.
|
||||
*
|
||||
* Naming caveat: the flag flips false for ANY failure mode, not just
|
||||
* literal network unreachability. A 401 (not signed in) or 403 (storage
|
||||
* disabled on the server) also flips it false - in both cases the user
|
||||
* cannot mutate server-side folders, so the disabled UX is correct, but
|
||||
* the tooltip wording should avoid claiming the user is "offline"
|
||||
* (they may not be). The shared i18n key `filesPage.offlineNoFolderEdits`
|
||||
* has been worded to cover all three cases.
|
||||
*/
|
||||
serverReachable: boolean;
|
||||
|
||||
currentFolderId: FolderId | null;
|
||||
setCurrentFolderId: (id: FolderId | null) => void;
|
||||
|
||||
breadcrumbs: FolderBreadcrumbEntry[];
|
||||
|
||||
/** Re-read the IDB cache (cheap; for revision-driven refresh). */
|
||||
refresh: () => Promise<void>;
|
||||
/**
|
||||
* Fetch from server, replace the local cache, and bump the revision.
|
||||
* Returns the result so the UI can distinguish "endpoint not deployed"
|
||||
* from real failures.
|
||||
*/
|
||||
pullFromServer: () => Promise<{
|
||||
ok: boolean;
|
||||
reason?: "endpoint-missing" | "network" | "server" | "client";
|
||||
}>;
|
||||
createFolder: (
|
||||
name: string,
|
||||
parentFolderId?: FolderId | null,
|
||||
) => Promise<FolderRecord>;
|
||||
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
|
||||
moveFolder: (
|
||||
id: FolderId,
|
||||
newParentId: FolderId | null,
|
||||
) => Promise<FolderRecord | null>;
|
||||
updateFolderAppearance: (
|
||||
id: FolderId,
|
||||
appearance: { color?: string; icon?: string | null },
|
||||
) => Promise<FolderRecord | null>;
|
||||
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
|
||||
|
||||
getChildFolderIds: (parentId: FolderId | null) => FolderId[];
|
||||
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
|
||||
}
|
||||
|
||||
const FolderContext = createContext<FolderContextValue | null>(null);
|
||||
|
||||
interface FolderProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function buildTree(folders: FolderRecord[]): FolderTreeNode[] {
|
||||
const byParent = new Map<FolderId | null, FolderRecord[]>();
|
||||
for (const folder of folders) {
|
||||
const list = byParent.get(folder.parentFolderId) ?? [];
|
||||
list.push(folder);
|
||||
byParent.set(folder.parentFolderId, list);
|
||||
}
|
||||
for (const list of byParent.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
// Cap recursion. Backend cycle detection + per-user folder cap already
|
||||
// prevent legitimately-deep chains, so any chain past this limit means
|
||||
// either a corrupted local cache or a malicious server response. Bail
|
||||
// out at the depth boundary so the JS call stack stays finite either
|
||||
// way; the user just sees the tree truncated at the bad node.
|
||||
const MAX_BUILD_DEPTH = 50;
|
||||
const build = (
|
||||
parentId: FolderId | null,
|
||||
depth: number,
|
||||
): FolderTreeNode[] => {
|
||||
if (depth >= MAX_BUILD_DEPTH) return [];
|
||||
const direct = byParent.get(parentId) ?? [];
|
||||
return direct.map((folder) => ({
|
||||
folder,
|
||||
depth,
|
||||
children: build(folder.id, depth + 1),
|
||||
}));
|
||||
};
|
||||
return build(ROOT_FOLDER_ID, 0);
|
||||
}
|
||||
|
||||
/** Convert a server-side error to a banner-ready user message. */
|
||||
function formatServerError(err: unknown): string {
|
||||
if (err && typeof err === "object" && "response" in err) {
|
||||
const response = (
|
||||
err as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
errors?: { defaultMessage?: string }[];
|
||||
};
|
||||
};
|
||||
}
|
||||
).response;
|
||||
const status = response?.status;
|
||||
const msg =
|
||||
response?.data?.message ??
|
||||
response?.data?.error ??
|
||||
response?.data?.errors?.[0]?.defaultMessage;
|
||||
if (msg) return status ? `${msg} (HTTP ${status})` : msg;
|
||||
}
|
||||
return err instanceof Error ? err.message : "Folder operation failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error's reachability signal. 5xx and "no status" (network) mean
|
||||
* the server is *not* reachable; 4xx means the server answered (just rejected
|
||||
* our request). Used to update {@link serverReachable} from mutation paths.
|
||||
*/
|
||||
function reachabilityFromError(err: unknown): boolean {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
return status !== undefined && status >= 400 && status < 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `currentId` or any of its ancestors is in `removedSet`. Walks up via
|
||||
* `parentFolderId` using the pre-removal `folders` snapshot so the chain is
|
||||
* still discoverable while the cascade is in flight. Used to force-reset
|
||||
* currentFolderId when the user was browsing a folder whose ancestor just
|
||||
* got deleted (otherwise the UI strands them on a folder id that no longer
|
||||
* exists on the server).
|
||||
*/
|
||||
function shouldStrandedReset(
|
||||
currentId: FolderId,
|
||||
removedSet: Set<FolderId>,
|
||||
folders: FolderRecord[],
|
||||
): boolean {
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
let cursor: FolderId | null = currentId;
|
||||
// Bounded walk: max 50 levels matches the existing depth guard elsewhere
|
||||
// and protects against malformed parent cycles.
|
||||
for (let i = 0; i < 50 && cursor; i++) {
|
||||
if (removedSet.has(cursor)) return true;
|
||||
const node = byId.get(cursor);
|
||||
cursor = (node?.parentFolderId ?? null) as FolderId | null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function FolderProvider({ children }: FolderProviderProps) {
|
||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Start `false` so folder-mutation buttons are disabled until the first
|
||||
// pullFromServer proves the backend is up. Starting `true` would let users
|
||||
// click "New folder" during the gap, then see a banner error after they
|
||||
// submitted the dialog.
|
||||
const [serverReachable, setServerReachable] = useState(false);
|
||||
const [currentFolderId, setCurrentFolderId] = useState<FolderId | null>(
|
||||
ROOT_FOLDER_ID,
|
||||
);
|
||||
// Tick when folder state changes so consumers re-read.
|
||||
const [folderRevision, setFolderRevision] = useState(0);
|
||||
const bumpFolderRevision = useCallback(() => {
|
||||
setFolderRevision((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const all = await folderStorage.getAllFolders();
|
||||
if (!mountedRef.current) return;
|
||||
setFolders(all);
|
||||
} catch (err) {
|
||||
console.error("[FolderContext] cache read failed", err);
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, folderRevision]);
|
||||
|
||||
// Single-flight guard - concurrent pullFromServer calls would race and the
|
||||
// last-resolving (often older) response would clobber the newer state.
|
||||
type PullResult = {
|
||||
ok: boolean;
|
||||
reason?: "endpoint-missing" | "network" | "server" | "client";
|
||||
};
|
||||
const pullInFlight = useRef<Promise<PullResult> | null>(null);
|
||||
|
||||
const pullFromServer = useCallback(async (): Promise<PullResult> => {
|
||||
if (pullInFlight.current) return pullInFlight.current;
|
||||
const promise: Promise<PullResult> = (async () => {
|
||||
let remote: FolderRecord[];
|
||||
try {
|
||||
remote = await folderSyncService.list();
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 404) {
|
||||
// Storage backend not deployed in this build - expected for
|
||||
// core-only.
|
||||
if (mountedRef.current) setServerReachable(false);
|
||||
return { ok: false, reason: "endpoint-missing" };
|
||||
}
|
||||
console.warn("[FolderContext] pullFromServer failed", err);
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(false);
|
||||
// Only surface a banner when this is a server-side outage or
|
||||
// network glitch the user can act on. 4xx responses are
|
||||
// configuration / auth signals - the deployment chose to disable
|
||||
// storage (403 "Storage is disabled") or the user simply isn't
|
||||
// logged in yet (401) - in both cases the "Folder sync failed"
|
||||
// banner is noise the user can't fix from inside the file
|
||||
// manager. Folder-mutation buttons get individual disabled
|
||||
// tooltips via `serverReachable`, which is enough signal.
|
||||
if (status === undefined || status >= 500) {
|
||||
setError(`Folder sync failed: ${formatServerError(err)}`);
|
||||
}
|
||||
}
|
||||
// Narrowing the ternary into a typed variable so TS keeps the literal
|
||||
// union rather than widening to `string`.
|
||||
const reason: PullResult["reason"] =
|
||||
status && status >= 500 ? "server" : status ? "client" : "network";
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
// Server-wins: cache becomes a verbatim copy of what the server
|
||||
// returned. A folder absent from the response was deleted server-side;
|
||||
// replaceAll drops it from the cache too.
|
||||
try {
|
||||
await folderStorage.replaceAll(remote);
|
||||
} catch (cacheErr) {
|
||||
// The server response was good; only the local cache write failed.
|
||||
// We still consider this an ok pull - render from in-memory state.
|
||||
console.warn("[FolderContext] cache replace failed", cacheErr);
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setFolders(remote);
|
||||
setServerReachable(true);
|
||||
setError(null);
|
||||
}
|
||||
bumpFolderRevision();
|
||||
return { ok: true };
|
||||
})();
|
||||
pullInFlight.current = promise;
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
pullInFlight.current = null;
|
||||
}
|
||||
}, [bumpFolderRevision]);
|
||||
|
||||
// Pull from server on mount - the IDB cache is for instant paint only;
|
||||
// we always reconcile with the server before letting the user mutate.
|
||||
// Short-circuit when AppConfig already tells us storage is off (desktop,
|
||||
// login disabled, or `storage.enabled=false`). The server-side
|
||||
// ConfigController computes `storageEnabled = enableLogin && storage.isEnabled`
|
||||
// so this single check covers all three failure modes - saves one
|
||||
// guaranteed-to-403 round-trip per session.
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const storageBackedByServer = appConfig?.storageEnabled === true;
|
||||
useEffect(() => {
|
||||
if (!storageBackedByServer) return;
|
||||
void pullFromServer();
|
||||
}, [pullFromServer, storageBackedByServer]);
|
||||
|
||||
const foldersById = useMemo(() => {
|
||||
const map = new Map<FolderId, FolderRecord>();
|
||||
for (const folder of folders) map.set(folder.id, folder);
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
const tree = useMemo(() => buildTree(folders), [folders]);
|
||||
|
||||
const breadcrumbs = useMemo<FolderBreadcrumbEntry[]>(() => {
|
||||
// Root name is a placeholder - consumers should detect
|
||||
// `entry.id === ROOT_FOLDER_ID` and substitute their own translated label.
|
||||
const path: FolderBreadcrumbEntry[] = [
|
||||
{ id: ROOT_FOLDER_ID, name: "All files" },
|
||||
];
|
||||
if (currentFolderId === null) return path;
|
||||
const chain: FolderRecord[] = [];
|
||||
let cursor: FolderId | null = currentFolderId;
|
||||
const seen = new Set<FolderId>();
|
||||
while (cursor !== null) {
|
||||
if (seen.has(cursor)) break;
|
||||
seen.add(cursor);
|
||||
const folder = foldersById.get(cursor);
|
||||
if (!folder) break;
|
||||
chain.unshift(folder);
|
||||
cursor = folder.parentFolderId;
|
||||
}
|
||||
for (const folder of chain) {
|
||||
path.push({ id: folder.id, name: folder.name });
|
||||
}
|
||||
return path;
|
||||
}, [currentFolderId, foldersById]);
|
||||
|
||||
const getChildFolderIds = useCallback(
|
||||
(parentId: FolderId | null): FolderId[] =>
|
||||
folders.filter((f) => f.parentFolderId === parentId).map((f) => f.id),
|
||||
[folders],
|
||||
);
|
||||
|
||||
const isDescendant = useCallback(
|
||||
(candidateId: FolderId, ancestorId: FolderId | null): boolean => {
|
||||
if (ancestorId === null) return true;
|
||||
let cursor: FolderId | null = candidateId;
|
||||
const seen = new Set<FolderId>();
|
||||
while (cursor !== null) {
|
||||
if (cursor === ancestorId) return true;
|
||||
if (seen.has(cursor)) return false;
|
||||
seen.add(cursor);
|
||||
cursor = foldersById.get(cursor)?.parentFolderId ?? null;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[foldersById],
|
||||
);
|
||||
|
||||
// ─── mutations: server-first, cache update on success ──────────────
|
||||
|
||||
/**
|
||||
* Centralised mutation wrapper:
|
||||
* 1. Calls the server op.
|
||||
* 2. On success: flips `serverReachable=true`, updates in-memory state
|
||||
* from the server response, then best-effort writes the cache (cache
|
||||
* failure does NOT roll back; the in-memory truth came from the server).
|
||||
* 3. On failure: updates `serverReachable` per the error class, surfaces
|
||||
* via {@link setError}, re-throws so the caller's dialog can stay open.
|
||||
*/
|
||||
const runFolderMutation = useCallback(
|
||||
async <T,>(
|
||||
serverOp: () => Promise<T>,
|
||||
onSuccess: (result: T) => void | Promise<void>,
|
||||
): Promise<T> => {
|
||||
let result: T;
|
||||
try {
|
||||
result = await serverOp();
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(reachabilityFromError(err));
|
||||
setError(formatServerError(err));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(true);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
await onSuccess(result);
|
||||
} catch (cacheErr) {
|
||||
// The server is authoritative - a cache write failure must not be
|
||||
// surfaced as if the operation failed. Log + leave the in-memory
|
||||
// state authoritative; next pullFromServer will re-seed the cache.
|
||||
console.warn(
|
||||
"[FolderContext] cache update failed after successful mutation",
|
||||
cacheErr,
|
||||
);
|
||||
}
|
||||
bumpFolderRevision();
|
||||
return result;
|
||||
},
|
||||
[bumpFolderRevision],
|
||||
);
|
||||
|
||||
const createFolder = useCallback(
|
||||
async (
|
||||
name: string,
|
||||
parentFolderId: FolderId | null = currentFolderId,
|
||||
): Promise<FolderRecord> => {
|
||||
const color = pickFolderColor(name);
|
||||
// Generate id client-side so the server's idempotency check makes
|
||||
// retries safe (network blip → second POST returns the same row).
|
||||
const id = createFolderId();
|
||||
return runFolderMutation(
|
||||
() =>
|
||||
folderSyncService.create({
|
||||
id,
|
||||
name,
|
||||
parentFolderId,
|
||||
color,
|
||||
}),
|
||||
async (record) => {
|
||||
setFolders((prev) => [
|
||||
...prev.filter((f) => f.id !== record.id),
|
||||
record,
|
||||
]);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
[currentFolderId, runFolderMutation],
|
||||
);
|
||||
|
||||
const renameFolder = useCallback(
|
||||
async (id: FolderId, name: string) => {
|
||||
return runFolderMutation(
|
||||
() => folderSyncService.update(id, { name }),
|
||||
async (record) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === record.id ? record : f)),
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
);
|
||||
|
||||
const moveFolder = useCallback(
|
||||
async (id: FolderId, newParentId: FolderId | null) => {
|
||||
return runFolderMutation(
|
||||
() =>
|
||||
folderSyncService.update(id, {
|
||||
reparent: true,
|
||||
parentFolderId: newParentId,
|
||||
}),
|
||||
async (record) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === record.id ? record : f)),
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
);
|
||||
|
||||
const updateFolderAppearance = useCallback(
|
||||
async (
|
||||
id: FolderId,
|
||||
appearance: { color?: string; icon?: string | null },
|
||||
) => {
|
||||
return runFolderMutation(
|
||||
() =>
|
||||
folderSyncService.update(id, {
|
||||
color: appearance.color,
|
||||
icon: appearance.icon,
|
||||
}),
|
||||
async (record) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === record.id ? record : f)),
|
||||
);
|
||||
await folderStorage.upsertFolder(record);
|
||||
},
|
||||
);
|
||||
},
|
||||
[runFolderMutation],
|
||||
);
|
||||
|
||||
const { clearFolderForFiles } = useIndexedDB();
|
||||
|
||||
const deleteFolder = useCallback(
|
||||
async (id: FolderId): Promise<FolderId[]> => {
|
||||
// Custom path (not runFolderMutation) because we have two best-effort
|
||||
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
|
||||
// cleanups so the user isn't stranded inside a tombstone if the cache
|
||||
// write fails.
|
||||
let removed: FolderId[];
|
||||
try {
|
||||
removed = await folderSyncService.delete(id);
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(reachabilityFromError(err));
|
||||
setError(formatServerError(err));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const removedSet = new Set(removed);
|
||||
if (mountedRef.current) {
|
||||
setServerReachable(true);
|
||||
setError(null);
|
||||
setFolders((prev) => prev.filter((f) => !removedSet.has(f.id)));
|
||||
// Reset if EITHER the current folder OR any ancestor was deleted.
|
||||
// Walking by parentFolderId catches the "user is browsing /a/b/c and
|
||||
// we just deleted /a" case where the exact-id check would leave the
|
||||
// UI pointing at /c which no longer exists.
|
||||
if (
|
||||
currentFolderId &&
|
||||
shouldStrandedReset(currentFolderId, removedSet, folders)
|
||||
) {
|
||||
setCurrentFolderId(ROOT_FOLDER_ID);
|
||||
}
|
||||
}
|
||||
bumpFolderRevision();
|
||||
// Belt-and-braces local cleanup; either failure shouldn't undo the
|
||||
// server delete or block the second cleanup.
|
||||
const [cacheResult, filesResult] = await Promise.allSettled([
|
||||
folderStorage.removeFolders(removed),
|
||||
clearFolderForFiles(removed),
|
||||
]);
|
||||
if (cacheResult.status === "rejected") {
|
||||
console.warn(
|
||||
"[FolderContext] folder cache cleanup failed after server delete",
|
||||
cacheResult.reason,
|
||||
);
|
||||
}
|
||||
if (filesResult.status === "rejected") {
|
||||
console.warn(
|
||||
"[FolderContext] file folderId cleanup failed after server delete",
|
||||
filesResult.reason,
|
||||
);
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
"Folder was deleted, but some files couldn't be detached locally. Refresh to fix.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
[bumpFolderRevision, clearFolderForFiles, currentFolderId, folders],
|
||||
);
|
||||
|
||||
const value = useMemo<FolderContextValue>(
|
||||
() => ({
|
||||
folders,
|
||||
foldersById,
|
||||
tree,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
serverReachable,
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
breadcrumbs,
|
||||
refresh,
|
||||
pullFromServer,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
moveFolder,
|
||||
updateFolderAppearance,
|
||||
deleteFolder,
|
||||
getChildFolderIds,
|
||||
isDescendant,
|
||||
}),
|
||||
[
|
||||
folders,
|
||||
foldersById,
|
||||
tree,
|
||||
loading,
|
||||
error,
|
||||
serverReachable,
|
||||
currentFolderId,
|
||||
breadcrumbs,
|
||||
refresh,
|
||||
pullFromServer,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
moveFolder,
|
||||
updateFolderAppearance,
|
||||
deleteFolder,
|
||||
getChildFolderIds,
|
||||
isDescendant,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<FolderContext.Provider value={value}>{children}</FolderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFolders(): FolderContextValue {
|
||||
const ctx = useContext(FolderContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useFolders must be used within a FolderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Optional version - returns null when used outside the provider. */
|
||||
export function useOptionalFolders(): FolderContextValue | null {
|
||||
return useContext(FolderContext);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId } from "@app/types/folder";
|
||||
import {
|
||||
StirlingFileStub,
|
||||
createStirlingFile,
|
||||
@@ -21,6 +23,9 @@ import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development";
|
||||
|
||||
/** LRU cap for the in-memory file blob cache. Module-scoped so it isn't recreated each render. */
|
||||
const MAX_CACHE_SIZE = 50;
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (
|
||||
@@ -47,7 +52,14 @@ interface IndexedDBContextValue {
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
|
||||
|
||||
// Incremented after any write or delete — subscribe to trigger re-reads
|
||||
// Folder operations
|
||||
moveFilesToFolder: (
|
||||
fileIds: FileId[],
|
||||
folderId: FolderId | null,
|
||||
) => Promise<FileId[]>;
|
||||
clearFolderForFiles: (folderIds: FolderId[]) => Promise<number>;
|
||||
|
||||
// Incremented after any write or delete - subscribe to trigger re-reads
|
||||
revision: number;
|
||||
bumpRevision: () => void;
|
||||
}
|
||||
@@ -66,7 +78,6 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const fileCache = useRef(
|
||||
new Map<FileId, { file: File; lastAccessed: number }>(),
|
||||
);
|
||||
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
|
||||
|
||||
// LRU cache management
|
||||
const evictLRUEntries = useCallback(() => {
|
||||
@@ -242,21 +253,62 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
[bumpRevision],
|
||||
);
|
||||
|
||||
const value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail,
|
||||
markFileAsProcessed,
|
||||
revision,
|
||||
bumpRevision,
|
||||
};
|
||||
const moveFilesToFolder = useCallback(
|
||||
async (fileIds: FileId[], folderId: FolderId | null): Promise<FileId[]> => {
|
||||
const updated = await fileStorage.moveFilesToFolder(fileIds, folderId);
|
||||
if (updated.length > 0) bumpRevision();
|
||||
return updated;
|
||||
},
|
||||
[bumpRevision],
|
||||
);
|
||||
|
||||
const clearFolderForFiles = useCallback(
|
||||
async (folderIds: FolderId[]): Promise<number> => {
|
||||
const cleared = await fileStorage.clearFolderForFiles(folderIds);
|
||||
if (cleared > 0) bumpRevision();
|
||||
return cleared;
|
||||
},
|
||||
[bumpRevision],
|
||||
);
|
||||
|
||||
// Memoise so identity-based context propagation doesn't re-render every
|
||||
// downstream consumer on each parent render.
|
||||
const value = useMemo<IndexedDBContextValue>(
|
||||
() => ({
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail,
|
||||
markFileAsProcessed,
|
||||
moveFilesToFolder,
|
||||
clearFolderForFiles,
|
||||
revision,
|
||||
bumpRevision,
|
||||
}),
|
||||
[
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail,
|
||||
markFileAsProcessed,
|
||||
moveFilesToFolder,
|
||||
clearFolderForFiles,
|
||||
revision,
|
||||
bumpRevision,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<IndexedDBContext.Provider value={value}>
|
||||
|
||||
@@ -240,6 +240,9 @@ interface AddFileOptions {
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
|
||||
/** Persist to IDB without dispatching to workspace state. */
|
||||
skipWorkspaceDispatch?: boolean;
|
||||
|
||||
// Auto-unzip control
|
||||
autoUnzip?: boolean;
|
||||
autoUnzipFileLimit?: number;
|
||||
@@ -498,8 +501,8 @@ export async function addFiles(
|
||||
});
|
||||
}
|
||||
|
||||
// Batch dispatch all files at once — one render instead of N sequential renders
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
// Batch dispatch in one render. Suppressed by skipWorkspaceDispatch.
|
||||
if (stirlingFileStubs.length > 0 && !options.skipWorkspaceDispatch) {
|
||||
dispatch({ type: "ADD_FILES", payload: { stirlingFileStubs } });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ export const useFileHandler = () => {
|
||||
const addFiles = useCallback(
|
||||
async (
|
||||
files: File[],
|
||||
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
|
||||
options: {
|
||||
insertAfterPageId?: string;
|
||||
selectFiles?: boolean;
|
||||
/** Persist to IDB without dispatching to workspace state. */
|
||||
skipWorkspaceDispatch?: boolean;
|
||||
} = {},
|
||||
): Promise<StirlingFile[]> => {
|
||||
// Merge default options with passed options - passed options take precedence
|
||||
const mergedOptions = { selectFiles: true, ...options };
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
touch-action: pan-x pinch-zoom;
|
||||
}
|
||||
|
||||
/* /files mobile: skip the slider entirely. The FileManagerView fills the
|
||||
viewport between the (suppressed) top toggle and the bottom nav, getting
|
||||
back ~120px of vertical room and avoiding the side-scroll surface. */
|
||||
.mobile-files-full {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-slider::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { Group } from "@mantine/core";
|
||||
@@ -15,18 +15,27 @@ import {
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import Workbench from "@app/components/layout/Workbench";
|
||||
import FileSidebar from "@app/components/shared/FileSidebar";
|
||||
import FileManager from "@app/components/FileManager";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
|
||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
||||
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
||||
import {
|
||||
FilesPageProvider,
|
||||
useFilesPage,
|
||||
} from "@app/contexts/FilesPageContext";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@@ -49,15 +58,18 @@ export default function HomePage() {
|
||||
customWorkbenchViews,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useIsMobile();
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
// Collapse the sidebar when mounting directly on /files.
|
||||
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(() =>
|
||||
location.pathname.startsWith("/files"),
|
||||
);
|
||||
|
||||
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
|
||||
// tour's openConfigModal action which navigates to /settings/overview).
|
||||
@@ -69,6 +81,46 @@ export default function HomePage() {
|
||||
const { activeFiles } = useFileContext();
|
||||
const navigationState = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
// Sync the /files* URL into the workbench state so the file manager view
|
||||
// takes over the workbench area when the user lands on it. This is the
|
||||
// only state-of-truth for the active workbench, so keep the URL pinned.
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith("/files")) {
|
||||
if (navigationState.workbench !== "myFiles") {
|
||||
actions.setWorkbench("myFiles");
|
||||
}
|
||||
} else if (navigationState.workbench === "myFiles") {
|
||||
// Leaving the file manager - drop back to a sensible default.
|
||||
actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer");
|
||||
}
|
||||
}, [
|
||||
location.pathname,
|
||||
navigationState.workbench,
|
||||
actions,
|
||||
activeFiles.length,
|
||||
]);
|
||||
|
||||
// Auto-collapse the FileSidebar on /files; snapshot prior state for restore.
|
||||
const previousSidebarCollapsedRef = useRef<boolean | null>(null);
|
||||
const prevWorkbenchRef = useRef(navigationState.workbench);
|
||||
useEffect(() => {
|
||||
const prev = prevWorkbenchRef.current;
|
||||
const curr = navigationState.workbench;
|
||||
if (curr === "myFiles" && prev !== "myFiles") {
|
||||
previousSidebarCollapsedRef.current = fileSidebarCollapsed;
|
||||
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
|
||||
} else if (
|
||||
curr !== "myFiles" &&
|
||||
prev === "myFiles" &&
|
||||
previousSidebarCollapsedRef.current !== null
|
||||
) {
|
||||
setFileSidebarCollapsed(previousSidebarCollapsedRef.current);
|
||||
previousSidebarCollapsedRef.current = null;
|
||||
}
|
||||
prevWorkbenchRef.current = curr;
|
||||
// fileSidebarCollapsed read as snapshot on transition only.
|
||||
}, [navigationState.workbench]);
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
const prevFileCountRef = useRef(activeFiles.length);
|
||||
|
||||
@@ -79,13 +131,6 @@ export default function HomePage() {
|
||||
const prevCount = prevFileCountRef.current;
|
||||
const currentCount = activeFiles.length;
|
||||
|
||||
console.log("[HomePage] Navigation effect triggered:", {
|
||||
prevCount,
|
||||
currentCount,
|
||||
currentWorkbench: navigationState.workbench,
|
||||
selectedToolKey,
|
||||
});
|
||||
|
||||
const action = getStartupNavigationAction(
|
||||
prevCount,
|
||||
currentCount,
|
||||
@@ -93,16 +138,11 @@ export default function HomePage() {
|
||||
navigationState.workbench,
|
||||
);
|
||||
|
||||
console.log("[HomePage] Navigation action returned:", action);
|
||||
|
||||
if (action) {
|
||||
console.log("[HomePage] Applying navigation:", action);
|
||||
actions.setWorkbench(action.workbench);
|
||||
if (typeof action.activeFileIndex === "number") {
|
||||
setActiveFileIndex(action.activeFileIndex);
|
||||
}
|
||||
} else {
|
||||
console.log("[HomePage] No navigation - staying in current workbench");
|
||||
}
|
||||
|
||||
prevFileCountRef.current = currentCount;
|
||||
@@ -115,9 +155,11 @@ export default function HomePage() {
|
||||
]);
|
||||
|
||||
const hideToolPanel =
|
||||
customWorkbenchViews.find(
|
||||
navigationState.workbench === "myFiles" ||
|
||||
(customWorkbenchViews.find(
|
||||
(v) => v.workbenchId === navigationState.workbench,
|
||||
)?.hideToolPanel ?? false;
|
||||
)?.hideToolPanel ??
|
||||
false);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
|
||||
@@ -241,154 +283,286 @@ export default function HomePage() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<HomePageExtensions />
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
<div className="mobile-toggle">
|
||||
<div className="mobile-header">
|
||||
<div className="mobile-brand">
|
||||
<LogoIcon className="mobile-brand-icon" />
|
||||
<Wordmark alt={brandAltText} className="mobile-brand-text" />
|
||||
<FilesPageProvider>
|
||||
{isMobile ? (
|
||||
<div
|
||||
className="mobile-layout"
|
||||
data-files-mode={navigationState.workbench === "myFiles"}
|
||||
>
|
||||
{/* On /files the FileManagerView already has its own Back +
|
||||
breadcrumb + tabs chrome - the tools/workspace toggle would
|
||||
just duplicate vertical space. Keep the toggle on every
|
||||
other route. */}
|
||||
{navigationState.workbench !== "myFiles" && (
|
||||
<div className="mobile-toggle">
|
||||
<div className="mobile-header">
|
||||
<div className="mobile-brand">
|
||||
<LogoIcon className="mobile-brand-icon" />
|
||||
<Wordmark
|
||||
alt={brandAltText}
|
||||
className="mobile-brand-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mobile-toggle-buttons"
|
||||
role="tablist"
|
||||
aria-label={t(
|
||||
"home.mobile.viewSwitcher",
|
||||
"Switch workspace view",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMobileView === "tools"}
|
||||
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
|
||||
onClick={() => handleSelectMobileView("tools")}
|
||||
>
|
||||
{t("home.mobile.tools", "Tools")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMobileView === "workbench"}
|
||||
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
|
||||
onClick={() => handleSelectMobileView("workbench")}
|
||||
>
|
||||
{t("home.mobile.workspace", "Workspace")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mobile-toggle-hint">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mobile-toggle-buttons"
|
||||
role="tablist"
|
||||
aria-label={t(
|
||||
"home.mobile.viewSwitcher",
|
||||
"Switch workspace view",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMobileView === "tools"}
|
||||
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
|
||||
onClick={() => handleSelectMobileView("tools")}
|
||||
>
|
||||
{t("home.mobile.tools", "Tools")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMobileView === "workbench"}
|
||||
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
|
||||
onClick={() => handleSelectMobileView("workbench")}
|
||||
>
|
||||
{t("home.mobile.workspace", "Workspace")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mobile-toggle-hint">
|
||||
{t(
|
||||
"home.mobile.swipeHint",
|
||||
"Swipe left or right to switch views",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={sliderRef} className="mobile-slider">
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<ToolPanel />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
)}
|
||||
{navigationState.workbench === "myFiles" ? (
|
||||
/* /files takes the whole viewport. Skipping the slider keeps
|
||||
the FileManagerView from being trapped inside a 100vw
|
||||
horizontal-scroll container (which truncated buttons and
|
||||
created a stray side-scroll surface on touch). */
|
||||
<div className="mobile-files-full">
|
||||
<div className="flex-1 min-h-0 flex" style={{ minWidth: 0 }}>
|
||||
<Workbench />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-bottom-bar">
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.allTools", "Tools")}
|
||||
onClick={() => {
|
||||
handleBackToTools();
|
||||
if (isMobile) {
|
||||
setActiveMobileView("tools");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppsIcon sx={{ fontSize: "1.5rem" }} />
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.allTools", "Tools")}
|
||||
</span>
|
||||
</button>
|
||||
{toolAvailability["automate"]?.available !== false && (
|
||||
) : (
|
||||
<div ref={sliderRef} className="mobile-slider">
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.toolsSlide",
|
||||
"Tool selection panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<ToolPanel />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mobile-slide"
|
||||
aria-label={t(
|
||||
"home.mobile.workbenchSlide",
|
||||
"Workspace panel",
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<div
|
||||
className="flex-1 min-h-0 flex"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
<Workbench />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-bottom-bar">
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.automate", "Automate")}
|
||||
aria-label={t("quickAccess.allTools", "Tools")}
|
||||
onClick={() => {
|
||||
handleToolSelect("automate");
|
||||
handleBackToTools();
|
||||
if (isMobile) {
|
||||
setActiveMobileView("tools");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppsIcon sx={{ fontSize: "1.5rem" }} />
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.allTools", "Tools")}
|
||||
</span>
|
||||
</button>
|
||||
{toolAvailability["automate"]?.available !== false && (
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.automate", "Automate")}
|
||||
onClick={() => {
|
||||
handleToolSelect("automate");
|
||||
if (isMobile) {
|
||||
setActiveMobileView("tools");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="automation-outline"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.automate", "Automate")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("home.mobile.openFiles", "Open files")}
|
||||
onClick={() => navigate("/files")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="automation-outline"
|
||||
icon="folder-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.automate", "Automate")}
|
||||
{t("quickAccess.files", "Files")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("home.mobile.openFiles", "Open files")}
|
||||
onClick={() => openFilesModal()}
|
||||
>
|
||||
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.files", "Files")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.config", "Config")}
|
||||
onClick={() => setConfigModalOpen(true)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="settings-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.config", "Config")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t("quickAccess.config", "Config")}
|
||||
onClick={() => setConfigModalOpen(true)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="settings-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
<span className="mobile-bottom-button-label">
|
||||
{t("quickAccess.config", "Config")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<FileManager selectedTool={selectedTool} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
|
||||
<FileSidebar
|
||||
ref={quickAccessRef}
|
||||
collapsed={fileSidebarCollapsed}
|
||||
onToggleCollapse={() => setFileSidebarCollapsed((c) => !c)}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
<Workbench />
|
||||
{!hideToolPanel && <ToolPanel />}
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
) : (
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap={0}
|
||||
h="100%"
|
||||
className="flex-nowrap flex"
|
||||
>
|
||||
<MyFilesAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
active={navigationState.workbench === "myFiles"}
|
||||
collapsed={fileSidebarCollapsed}
|
||||
toggleAriaLabel={
|
||||
navigationState.workbench === "myFiles"
|
||||
? t("fileSidebar.leaveMyFiles", "Leave My Files")
|
||||
: undefined
|
||||
}
|
||||
// Back-arrow on /files; burger elsewhere.
|
||||
toggleIcon={
|
||||
navigationState.workbench === "myFiles" ? (
|
||||
<ArrowBackIcon />
|
||||
) : undefined
|
||||
}
|
||||
onToggleCollapse={() => {
|
||||
if (navigationState.workbench === "myFiles") {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
setFileSidebarCollapsed((c) => !c);
|
||||
}}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||
<Workbench />
|
||||
{!hideToolPanel && <ToolPanel />}
|
||||
<FileManager selectedTool={selectedTool} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</FilesPageProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MyFilesAwareFileSidebarProps extends FileSidebarProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Wraps FileSidebar with /files-aware overrides when `active`. */
|
||||
const MyFilesAwareFileSidebar = forwardRef<
|
||||
HTMLDivElement,
|
||||
MyFilesAwareFileSidebarProps
|
||||
>(function MyFilesAwareFileSidebar(props, ref) {
|
||||
const { active, ...rest } = props;
|
||||
if (!active) {
|
||||
return <FileSidebar ref={ref} {...rest} />;
|
||||
}
|
||||
return <MyFilesSidebarOverrides ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function MyFilesSidebarOverrides(props, ref) {
|
||||
const { t } = useTranslation();
|
||||
const filesPage = useFilesPage();
|
||||
const folders = useFolders();
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
const added = await addFiles(files, { skipWorkspaceDispatch: true });
|
||||
await filesPage.refresh();
|
||||
// If the user is inside a cloud folder, place uploads there.
|
||||
if (folders.currentFolderId !== null && added.length > 0) {
|
||||
await filesPage.moveFilesTo(
|
||||
added.map((f) => f.fileId),
|
||||
folders.currentFolderId,
|
||||
);
|
||||
}
|
||||
},
|
||||
[addFiles, filesPage, folders.currentFolderId],
|
||||
);
|
||||
|
||||
const newFolderDisabledReason = !folders.serverReachable
|
||||
? t(
|
||||
"filesPage.newFolderStorageDisabled",
|
||||
"Server folder storage isn't enabled. Ask your admin to turn it on.",
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FileSidebar
|
||||
ref={ref}
|
||||
{...props}
|
||||
onSearchClick={() => {
|
||||
// Just focus the central search field; don't toggle collapse
|
||||
// (which on /files navigates back home).
|
||||
window.dispatchEvent(new Event("files-page:focus-search"));
|
||||
}}
|
||||
onUploadFiles={handleUpload}
|
||||
onPickGoogleDriveFiles={handleUpload}
|
||||
extraAction={{
|
||||
icon: <CreateNewFolderIcon />,
|
||||
label: t("filesPage.newFolder", "New folder"),
|
||||
onClick: () => filesPage.openNewFolderDialog(),
|
||||
disabled: newFolderDisabledReason !== null,
|
||||
disabledTooltip: newFolderDisabledReason ?? undefined,
|
||||
testId: "files-rail-new-folder",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FileId, BaseFileMetadata } from "@app/types/file";
|
||||
import { FolderId } from "@app/types/folder";
|
||||
import {
|
||||
StirlingFile,
|
||||
StirlingFileStub,
|
||||
@@ -26,7 +27,7 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
|
||||
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
|
||||
thumbnail?: string;
|
||||
thumbnailStoredAt?: number; // Epoch ms — sliding 30-day TTL
|
||||
thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL
|
||||
url?: string; // For compatibility with existing components
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ class FileStorageService {
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
|
||||
// Issue all gets up front — each onsuccess creates a put before the
|
||||
// Issue all gets up front - each onsuccess creates a put before the
|
||||
// transaction can auto-commit, keeping it alive until all puts settle.
|
||||
ids.forEach((id) => {
|
||||
const req = store.get(id);
|
||||
@@ -123,6 +124,9 @@ class FileStorageService {
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? [],
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -215,9 +219,12 @@ class FileStorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create StirlingFileStub from metadata (no file data)
|
||||
// No per-id thumbnail TTL bump here - the bulk getAll/leaf paths
|
||||
// already keep TTL fresh, and bumping on every single-id read
|
||||
// generated a writable transaction per call (write amplification).
|
||||
// We still gate thumbnailUrl on freshness so stale thumbnails
|
||||
// don't leak through this read path.
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
void this.bumpThumbnailTTL([record.id], !fresh);
|
||||
|
||||
const stub: StirlingFileStub = {
|
||||
id: record.id,
|
||||
@@ -240,6 +247,7 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
|
||||
@@ -295,13 +303,24 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
void this.bumpThumbnailTTL(tobump);
|
||||
void this.bumpThumbnailTTL(toexpire, true);
|
||||
// Only open the writeback transaction when there's something to do -
|
||||
// previously fired two empty transactions per refresh.
|
||||
if (tobump.length > 0) {
|
||||
void this.bumpThumbnailTTL(tobump).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail TTL bump failed", e),
|
||||
);
|
||||
}
|
||||
if (toexpire.length > 0) {
|
||||
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail expire failed", e),
|
||||
);
|
||||
}
|
||||
resolve(stubs);
|
||||
}
|
||||
};
|
||||
@@ -372,19 +391,112 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
void this.bumpThumbnailTTL(tobump);
|
||||
void this.bumpThumbnailTTL(toexpire, true);
|
||||
if (tobump.length > 0) {
|
||||
void this.bumpThumbnailTTL(tobump).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail TTL bump failed", e),
|
||||
);
|
||||
}
|
||||
if (toexpire.length > 0) {
|
||||
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
|
||||
console.warn("[fileStorage] thumbnail expire failed", e),
|
||||
);
|
||||
}
|
||||
resolve(leafStubs);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one or more files into a folder (or to the root when folderId is null).
|
||||
* Returns the ids of records that were actually updated.
|
||||
*/
|
||||
async moveFilesToFolder(
|
||||
fileIds: FileId[],
|
||||
folderId: FolderId | null,
|
||||
): Promise<FileId[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
const db = await this.getDatabase();
|
||||
const updated: FileId[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("Move transaction aborted"));
|
||||
|
||||
fileIds.forEach((id) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) return;
|
||||
record.folderId = folderId;
|
||||
store.put(record);
|
||||
updated.push(id);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the folderId for every file currently inside any of the given
|
||||
* folders. Used when a folder (or subtree) is deleted so the contents
|
||||
* fall back to the root rather than dangling against a missing folder.
|
||||
*/
|
||||
async clearFolderForFiles(folderIds: FolderId[]): Promise<number> {
|
||||
if (folderIds.length === 0) return 0;
|
||||
const db = await this.getDatabase();
|
||||
let cleared = 0;
|
||||
|
||||
// Use the `folderId` index (declared on the files store in
|
||||
// indexedDBManager DATABASE_CONFIGS) with one keyRange-bounded cursor
|
||||
// per folderId. The previous full-store openCursor() was O(total files);
|
||||
// this is O(files-in-affected-folders + folderIds.length). On users
|
||||
// with thousands of files and a single deleted folder this is a 100x+
|
||||
// win and keeps the UI responsive while the transaction runs.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const index = store.index("folderId");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(
|
||||
transaction.error ?? new Error("Clear folder transaction aborted"),
|
||||
);
|
||||
|
||||
for (const folderId of folderIds) {
|
||||
const cursorRequest = index.openCursor(
|
||||
IDBKeyRange.only(folderId as string),
|
||||
);
|
||||
cursorRequest.onerror = () => reject(cursorRequest.error);
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest)
|
||||
.result as IDBCursorWithValue | null;
|
||||
if (!cursor) return;
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
record.folderId = null;
|
||||
cursor.update(record);
|
||||
cleared += 1;
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete StirlingFile - single operation, no sync issues
|
||||
*/
|
||||
@@ -628,6 +740,11 @@ class FileStorageService {
|
||||
|
||||
/**
|
||||
* Update metadata fields for a stored file record.
|
||||
*
|
||||
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
|
||||
* so callers only receive `true` once the write actually commits. If the
|
||||
* transaction aborts after put() succeeded but before commit, we return false
|
||||
* - the previous behavior incorrectly claimed success in that window.
|
||||
*/
|
||||
async updateFileMetadata(
|
||||
fileId: FileId,
|
||||
@@ -635,29 +752,31 @@ class FileStorageService {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () =>
|
||||
resolve(request.result as StoredStirlingFileRecord | undefined);
|
||||
},
|
||||
);
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
let recordFound = false;
|
||||
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
if (!record) {
|
||||
// Don't commit anything; caller wants false.
|
||||
return;
|
||||
}
|
||||
recordFound = true;
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
store.put(updatedRecord);
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(updatedRecord);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.oncomplete = () => resolve(recordFound);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to update file metadata:", error);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Reconciles the local IDB file stub list with the server's view of which
|
||||
* files the user can see. Server is source of truth for cloud files; local
|
||||
* IDB caches them so the grid renders instantly while the server call is in
|
||||
* flight and so local-only files survive offline.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import { FolderId, parseFolderId } from "@app/types/folder";
|
||||
import {
|
||||
isZipBundle,
|
||||
loadShareBundleEntries,
|
||||
parseContentDispositionFilename,
|
||||
} from "@app/services/shareBundleUtils";
|
||||
|
||||
/**
|
||||
* Trust-boundary parser for folderId values coming back from the server.
|
||||
* Matches folderSyncService's discipline - we never want a garbage server
|
||||
* payload to corrupt the local IDB `folderId` index. Returns null on any
|
||||
* invalid input so the file falls back to the root folder.
|
||||
*/
|
||||
function safeParseFolderId(value: unknown): FolderId | null {
|
||||
if (value == null || value === "") return null;
|
||||
try {
|
||||
return parseFolderId(value);
|
||||
} catch {
|
||||
console.warn("[fileSyncService] dropping invalid server folderId", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredFileResponse {
|
||||
id: number;
|
||||
fileName: string;
|
||||
contentType?: string | null;
|
||||
sizeBytes: number;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
owner?: string | null;
|
||||
ownedByCurrentUser?: boolean;
|
||||
accessRole?: string | null;
|
||||
shareLinks?: Array<{ token?: string | null }>;
|
||||
sharedUsers?: Array<{ username?: string | null }>;
|
||||
sharedWithUsers?: string[];
|
||||
filePurpose?: string | null;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
interface AccessedShareLinkResponse {
|
||||
shareToken?: string | null;
|
||||
fileId?: number | null;
|
||||
fileName?: string | null;
|
||||
owner?: string | null;
|
||||
ownedByCurrentUser?: boolean;
|
||||
createdAt?: string | null;
|
||||
lastAccessedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ReconcileOptions {
|
||||
storageEnabled: boolean;
|
||||
shareLinksEnabled: boolean;
|
||||
}
|
||||
|
||||
function normalizeServerFileName(fileName: string | undefined | null): string {
|
||||
const fallback = fileName?.trim() || "server-file";
|
||||
const lower = fallback.toLowerCase();
|
||||
const historySuffix = "-history.zip";
|
||||
if (lower.endsWith(historySuffix)) {
|
||||
return (
|
||||
fallback.slice(0, fallback.length - historySuffix.length) || fallback
|
||||
);
|
||||
}
|
||||
if (lower.endsWith(".zip")) {
|
||||
const innerExts = [
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"tif",
|
||||
"tiff",
|
||||
"txt",
|
||||
"csv",
|
||||
"rtf",
|
||||
"html",
|
||||
"epub",
|
||||
];
|
||||
for (const ext of innerExts) {
|
||||
if (lower.endsWith(`.${ext}.zip`)) {
|
||||
return fallback.slice(0, fallback.length - 4) || fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Pull the server file list (and share-links) and reconcile with local stubs. */
|
||||
export async function reconcileServerFiles(
|
||||
localStubs: StirlingFileStub[],
|
||||
opts: ReconcileOptions,
|
||||
): Promise<StirlingFileStub[]> {
|
||||
if (!opts.storageEnabled) {
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
let combinedStubs: StirlingFileStub[];
|
||||
const localRemoteIds = new Set(
|
||||
localStubs
|
||||
.map((s) => s.remoteStorageId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<StoredFileResponse[]>(
|
||||
"/api/v1/storage/files",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const serverFiles = Array.isArray(response.data) ? response.data : [];
|
||||
const serverMap = new Map<number, StoredFileResponse>();
|
||||
for (const file of serverFiles) {
|
||||
if (file && typeof file.id === "number") {
|
||||
serverMap.set(file.id, file);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedLocalStubs = localStubs.map((stub) => {
|
||||
if (!stub.remoteStorageId) {
|
||||
return stub;
|
||||
}
|
||||
const serverFile = serverMap.get(stub.remoteStorageId);
|
||||
if (!serverFile) {
|
||||
// Server no longer knows this file; if it was a shared-link link,
|
||||
// demote rather than detach. Otherwise drop remote metadata so the
|
||||
// file becomes local-only.
|
||||
if (stub.remoteSharedViaLink) {
|
||||
return { ...stub, remoteOwnedByCurrentUser: false };
|
||||
}
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteAccessRole: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
};
|
||||
}
|
||||
if (serverFile.filePurpose && serverFile.filePurpose !== "generic") {
|
||||
// Signing-workflow files aren't user-facing in the file manager.
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteAccessRole: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
};
|
||||
}
|
||||
const updatedAtMs = serverFile.updatedAt
|
||||
? new Date(serverFile.updatedAt).getTime()
|
||||
: serverFile.createdAt
|
||||
? new Date(serverFile.createdAt).getTime()
|
||||
: undefined;
|
||||
return {
|
||||
...stub,
|
||||
remoteOwnerUsername: serverFile.owner ?? stub.remoteOwnerUsername,
|
||||
remoteOwnedByCurrentUser:
|
||||
typeof serverFile.ownedByCurrentUser === "boolean"
|
||||
? serverFile.ownedByCurrentUser
|
||||
: stub.remoteOwnedByCurrentUser,
|
||||
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
|
||||
remoteSharedViaLink: stub.remoteSharedViaLink,
|
||||
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
|
||||
remoteHasUserShares: Boolean(
|
||||
serverFile.sharedUsers?.length || serverFile.sharedWithUsers?.length,
|
||||
),
|
||||
remoteStorageUpdatedAt:
|
||||
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
|
||||
? updatedAtMs
|
||||
: stub.remoteStorageUpdatedAt,
|
||||
folderId: safeParseFolderId(serverFile.folderId) ?? stub.folderId,
|
||||
};
|
||||
});
|
||||
|
||||
// Server files that this browser hasn't cached yet become ephemeral
|
||||
// stubs (id="server-{N}", no IDB row). Bytes get fetched on demand.
|
||||
const serverStubs: StirlingFileStub[] = [];
|
||||
for (const file of serverFiles) {
|
||||
if (!file || typeof file.id !== "number") continue;
|
||||
if (localRemoteIds.has(file.id)) continue;
|
||||
if (file.filePurpose && file.filePurpose !== "generic") continue;
|
||||
const updatedAtMs = file.updatedAt
|
||||
? new Date(file.updatedAt).getTime()
|
||||
: file.createdAt
|
||||
? new Date(file.createdAt).getTime()
|
||||
: Date.now();
|
||||
const name = normalizeServerFileName(file.fileName);
|
||||
const lastModified = Number.isFinite(updatedAtMs)
|
||||
? updatedAtMs
|
||||
: Date.now();
|
||||
const id = `server-${file.id}` as FileId;
|
||||
serverStubs.push({
|
||||
id,
|
||||
name,
|
||||
type: file.contentType || "application/octet-stream",
|
||||
size: file.sizeBytes ?? 0,
|
||||
lastModified,
|
||||
createdAt: lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: `${name}|${file.sizeBytes ?? 0}|${lastModified}`,
|
||||
remoteStorageId: file.id,
|
||||
remoteStorageUpdatedAt: lastModified,
|
||||
remoteOwnerUsername: file.owner ?? undefined,
|
||||
remoteOwnedByCurrentUser:
|
||||
typeof file.ownedByCurrentUser === "boolean"
|
||||
? file.ownedByCurrentUser
|
||||
: undefined,
|
||||
remoteAccessRole: file.accessRole ?? undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: Boolean(file.shareLinks?.length),
|
||||
remoteHasUserShares: Boolean(
|
||||
file.sharedUsers?.length || file.sharedWithUsers?.length,
|
||||
),
|
||||
folderId: safeParseFolderId(file.folderId),
|
||||
});
|
||||
}
|
||||
|
||||
combinedStubs = [...updatedLocalStubs, ...serverStubs];
|
||||
} catch (err) {
|
||||
// Surface to the user so they don't silently see only locally-cached
|
||||
// files and assume their cloud data is lost. Toast deduplicates by title
|
||||
// so a repeated failure isn't an avalanche of identical popups.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
console.warn("[fileSyncService] failed to pull server files", err);
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title:
|
||||
status === 401
|
||||
? "Sign-in required to load cloud files"
|
||||
: "Could not reach the cloud library",
|
||||
body: "Showing only files cached in this browser. Refresh to retry once the connection is back.",
|
||||
expandable: false,
|
||||
durationMs: 5000,
|
||||
});
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
if (!opts.shareLinksEnabled) {
|
||||
return combinedStubs;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<AccessedShareLinkResponse[]>(
|
||||
"/api/v1/storage/share-links/accessed",
|
||||
{ suppressErrorToast: true, skipAuthRedirect: true } as any,
|
||||
);
|
||||
const sharedLinks = Array.isArray(response.data) ? response.data : [];
|
||||
const allowed = new Set(
|
||||
sharedLinks
|
||||
.map((l) => l.shareToken)
|
||||
.filter((t): t is string => Boolean(t)),
|
||||
);
|
||||
const writes: Array<Promise<boolean>> = [];
|
||||
combinedStubs = combinedStubs.map((stub) => {
|
||||
if (
|
||||
stub.remoteSharedViaLink &&
|
||||
stub.remoteShareToken &&
|
||||
!allowed.has(stub.remoteShareToken)
|
||||
) {
|
||||
writes.push(
|
||||
fileStorage.updateFileMetadata(stub.id, {
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
remoteShareToken: undefined,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...stub,
|
||||
remoteStorageId: undefined,
|
||||
remoteStorageUpdatedAt: undefined,
|
||||
remoteOwnerUsername: undefined,
|
||||
remoteOwnedByCurrentUser: undefined,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: undefined,
|
||||
remoteShareToken: undefined,
|
||||
};
|
||||
}
|
||||
return stub;
|
||||
});
|
||||
if (writes.length > 0) {
|
||||
// Fire-and-forget; the in-memory list is the user-visible source.
|
||||
void Promise.all(writes).catch(() => {});
|
||||
}
|
||||
|
||||
// Synthesize ephemeral shared-{token} stubs for share-links the user has
|
||||
// accessed but doesn't have cached locally yet. Materialize on demand.
|
||||
const existingShareTokens = new Set(
|
||||
combinedStubs
|
||||
.map((stub) => stub.remoteShareToken)
|
||||
.filter((token): token is string => Boolean(token)),
|
||||
);
|
||||
const sharedStubs: StirlingFileStub[] = [];
|
||||
for (const link of sharedLinks) {
|
||||
if (!link || !link.shareToken) continue;
|
||||
if (existingShareTokens.has(link.shareToken)) continue;
|
||||
const accessedMs = link.lastAccessedAt
|
||||
? new Date(link.lastAccessedAt).getTime()
|
||||
: link.createdAt
|
||||
? new Date(link.createdAt).getTime()
|
||||
: Date.now();
|
||||
const lastModified = Number.isFinite(accessedMs)
|
||||
? accessedMs
|
||||
: Date.now();
|
||||
const name = normalizeServerFileName(link.fileName || "shared-file");
|
||||
const id = `shared-${link.shareToken}` as FileId;
|
||||
sharedStubs.push({
|
||||
id,
|
||||
name,
|
||||
type: "application/octet-stream",
|
||||
size: 0,
|
||||
lastModified,
|
||||
createdAt: lastModified,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
toolHistory: [],
|
||||
quickKey: `${name}|0|${lastModified}`,
|
||||
remoteStorageId: link.fileId ?? undefined,
|
||||
remoteStorageUpdatedAt: lastModified,
|
||||
remoteOwnerUsername: link.owner ?? undefined,
|
||||
remoteOwnedByCurrentUser: false,
|
||||
remoteSharedViaLink: true,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: link.shareToken,
|
||||
});
|
||||
}
|
||||
combinedStubs = [...combinedStubs, ...sharedStubs];
|
||||
} catch (err) {
|
||||
console.warn("[fileSyncService] failed to pull share-links", err);
|
||||
}
|
||||
|
||||
return combinedStubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download bytes for any server-only stubs (id starts with "server-") and
|
||||
* ingest them into IDB. Returns a stub list where the server-only entries
|
||||
* are replaced with proper local stubs that point to the freshly-cached
|
||||
* IDB rows. Local stubs are passed through untouched.
|
||||
*
|
||||
* Pass `addFiles` (from FileContext.addFilesWithOptions) and
|
||||
* `updateStub` (from FileContext.updateStirlingFileStub) so this util
|
||||
* stays React-free; the caller provides the wiring.
|
||||
*/
|
||||
export async function materializeServerStubs(
|
||||
stubs: StirlingFileStub[],
|
||||
helpers: {
|
||||
addFiles: (
|
||||
files: File[],
|
||||
options: {
|
||||
selectFiles: boolean;
|
||||
autoUnzip: boolean;
|
||||
skipAutoUnzip: boolean;
|
||||
allowDuplicates: boolean;
|
||||
},
|
||||
) => Promise<StirlingFile[]>;
|
||||
updateStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
|
||||
},
|
||||
): Promise<StirlingFileStub[]> {
|
||||
const out: StirlingFileStub[] = [];
|
||||
// Collect per-stub failures so we can surface ONE summarized toast at the
|
||||
// end instead of N popups (or worse, silently dropping files from the grid
|
||||
// with zero user signal as the previous code did).
|
||||
const failed: { name: string; status?: number }[] = [];
|
||||
for (const stub of stubs) {
|
||||
const isServerStub =
|
||||
typeof stub.id === "string" &&
|
||||
stub.id.startsWith("server-") &&
|
||||
typeof stub.remoteStorageId === "number";
|
||||
const isSharedStub =
|
||||
typeof stub.id === "string" &&
|
||||
stub.id.startsWith("shared-") &&
|
||||
typeof stub.remoteShareToken === "string";
|
||||
if (!isServerStub && !isSharedStub) {
|
||||
out.push(stub);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const downloadUrl = isSharedStub
|
||||
? `/api/v1/storage/share-links/${stub.remoteShareToken}`
|
||||
: `/api/v1/storage/files/${stub.remoteStorageId}/download`;
|
||||
const response = await apiClient.get(downloadUrl, {
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any);
|
||||
const rawHeaders = (response.headers ?? {}) as Record<string, unknown> & {
|
||||
get?: (name: string) => string | null;
|
||||
};
|
||||
const readHeader = (name: string): string => {
|
||||
if (typeof rawHeaders.get === "function") {
|
||||
return rawHeaders.get(name) ?? "";
|
||||
}
|
||||
const lower = rawHeaders[name];
|
||||
const upper =
|
||||
rawHeaders[
|
||||
name.replace(/(^|-)([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase())
|
||||
];
|
||||
return (
|
||||
(typeof lower === "string" && lower) ||
|
||||
(typeof upper === "string" && upper) ||
|
||||
""
|
||||
);
|
||||
};
|
||||
const contentType = readHeader("content-type");
|
||||
const disposition = readHeader("content-disposition");
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || stub.name;
|
||||
const blob = response.data as Blob;
|
||||
|
||||
// Server bundle: extract latest file(s) inside.
|
||||
const bundle = isZipBundle(contentType, filename)
|
||||
? await loadShareBundleEntries(blob).catch(() => null)
|
||||
: null;
|
||||
const files: File[] = bundle
|
||||
? bundle.files
|
||||
: [new File([blob], filename, { type: contentType || blob.type })];
|
||||
|
||||
const ingested = await helpers.addFiles(files, {
|
||||
selectFiles: false,
|
||||
autoUnzip: false,
|
||||
skipAutoUnzip: true,
|
||||
allowDuplicates: true,
|
||||
});
|
||||
if (ingested.length === 0) continue;
|
||||
const primary = ingested[ingested.length - 1]!;
|
||||
const newId = primary.fileId as FileId;
|
||||
const remoteUpdates = {
|
||||
remoteStorageId: stub.remoteStorageId,
|
||||
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
|
||||
remoteOwnerUsername: stub.remoteOwnerUsername,
|
||||
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
|
||||
remoteAccessRole: stub.remoteAccessRole,
|
||||
remoteSharedViaLink: isSharedStub ? true : false,
|
||||
remoteHasShareLinks: stub.remoteHasShareLinks,
|
||||
remoteShareToken: isSharedStub ? stub.remoteShareToken : undefined,
|
||||
};
|
||||
helpers.updateStub(newId, remoteUpdates);
|
||||
await fileStorage.updateFileMetadata(newId, remoteUpdates);
|
||||
out.push({ ...stub, ...remoteUpdates, id: newId, originalFileId: newId });
|
||||
} catch (err) {
|
||||
console.warn("[fileSyncService] failed to materialize server stub", err);
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
failed.push({ name: stub.name, status });
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Single summarized toast - far less noisy than per-stub alerts but
|
||||
// still surfaces what would otherwise be a silent drop from the grid.
|
||||
const first = failed[0]!;
|
||||
const bodyText =
|
||||
failed.length === 1
|
||||
? `Couldn't open "${first.name}"${first.status ? ` (HTTP ${first.status})` : ""}.`
|
||||
: `Couldn't open ${failed.length} files including "${first.name}".`;
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: "Some files couldn't be opened",
|
||||
body: bodyText,
|
||||
expandable: false,
|
||||
durationMs: 5000,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Folder Storage Service - passive read-cache of the server's folder hierarchy.
|
||||
*
|
||||
* Folders are server-owned. This module just persists the most recent server
|
||||
* response so the UI can paint instantly on next mount (and remain readable
|
||||
* offline). Every mutation must go through {@code folderSyncService} first;
|
||||
* on success the caller invokes {@link FolderStorageService.replaceAll} or
|
||||
* one of the targeted updaters to keep the cache in step.
|
||||
*
|
||||
* No id generation here, no cycle detection, no idempotency tricks - those
|
||||
* are all the server's job now.
|
||||
*/
|
||||
|
||||
import { FolderId, FolderRecord } from "@app/types/folder";
|
||||
import {
|
||||
indexedDBManager,
|
||||
DATABASE_CONFIGS,
|
||||
} from "@app/services/indexedDBManager";
|
||||
|
||||
class FolderStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "folders";
|
||||
|
||||
private async getDatabase(): Promise<IDBDatabase> {
|
||||
return indexedDBManager.openDatabase(this.dbConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace the entire cached folder set with the supplied list.
|
||||
* Used after a successful {@code pullFromServer} - the server is the
|
||||
* source of truth, so any folder absent from the response is dropped
|
||||
* locally too (no orphan rows surviving a server-side delete).
|
||||
*/
|
||||
async replaceAll(folders: FolderRecord[]): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("folder cache replace failed"));
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("folder cache replace aborted"));
|
||||
store.clear();
|
||||
for (const folder of folders) {
|
||||
store.put(folder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert or overwrite a single folder in the cache. */
|
||||
async upsertFolder(folder: FolderRecord): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const req = store.put(folder);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a set of folders from the cache (after a successful server delete). */
|
||||
async removeFolders(ids: FolderId[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const db = await this.getDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("folder cache delete failed"));
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("folder cache delete aborted"));
|
||||
for (const id of ids) store.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllFolders(): Promise<FolderRecord[]> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const records = (request.result as FolderRecord[]) ?? [];
|
||||
resolve(records);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getFolder(id: FolderId): Promise<FolderRecord | null> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as FolderRecord | undefined;
|
||||
resolve(record ?? null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const folderStorage = new FolderStorageService();
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Thin HTTP client for the Phase A folder endpoints.
|
||||
*
|
||||
* Returns DTOs that look like the local {@link FolderRecord}; the
|
||||
* caller is responsible for merging them into IndexedDB via
|
||||
* {@link folderStorage}.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { FolderId, FolderRecord, parseFolderId } from "@app/types/folder";
|
||||
|
||||
interface ServerFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolderId: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
version: number | null;
|
||||
// ISO timestamps - older server builds occasionally send `null` when
|
||||
// the entity hasn't been flushed; we defend against that in parseTimestamp.
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
function parseTimestamp(
|
||||
value: string | null | undefined,
|
||||
field: string,
|
||||
): number {
|
||||
// Tolerate null/missing - older server builds may serialise pre-flush
|
||||
// timestamps as null. Log a warning so a real schema drift still gets
|
||||
// attention, but fall back to "now" rather than failing the whole pull.
|
||||
if (value == null || value === "") {
|
||||
console.warn(
|
||||
`[folderSyncService] missing ${field} from server response; defaulting to now`,
|
||||
);
|
||||
return Date.now();
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
if (Number.isNaN(ms)) {
|
||||
throw new Error(`Invalid ${field} timestamp from server: ${value}`);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
function toFolderRecord(dto: ServerFolder): FolderRecord {
|
||||
// Validate at the trust boundary - server may have a bug or contract drift.
|
||||
// parseFolderId throws if `dto.id` isn't a UUID; better a loud failure than
|
||||
// a corrupt local cache.
|
||||
const id = parseFolderId(dto.id);
|
||||
const parentFolderId =
|
||||
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
|
||||
return {
|
||||
id,
|
||||
name: dto.name,
|
||||
parentFolderId,
|
||||
color: dto.color ?? undefined,
|
||||
icon: dto.icon ?? undefined,
|
||||
createdAt: parseTimestamp(dto.createdAt, "createdAt"),
|
||||
updatedAt: parseTimestamp(dto.updatedAt, "updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export const folderSyncService = {
|
||||
async list(): Promise<FolderRecord[]> {
|
||||
const response = await apiClient.get<ServerFolder[]>(
|
||||
"/api/v1/storage/folders",
|
||||
);
|
||||
return (response.data ?? []).map(toFolderRecord);
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
id?: FolderId;
|
||||
name: string;
|
||||
parentFolderId: FolderId | null;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}): Promise<FolderRecord> {
|
||||
const response = await apiClient.post<ServerFolder>(
|
||||
"/api/v1/storage/folders",
|
||||
{
|
||||
id: input.id ?? undefined,
|
||||
name: input.name,
|
||||
parentFolderId: input.parentFolderId,
|
||||
color: input.color,
|
||||
icon: input.icon,
|
||||
},
|
||||
);
|
||||
return toFolderRecord(response.data);
|
||||
},
|
||||
|
||||
async update(
|
||||
id: FolderId,
|
||||
patch: {
|
||||
name?: string;
|
||||
reparent?: boolean;
|
||||
parentFolderId?: FolderId | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
},
|
||||
): Promise<FolderRecord> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (patch.name !== undefined) body.name = patch.name;
|
||||
if (patch.reparent) {
|
||||
body.reparent = true;
|
||||
body.parentFolderId = patch.parentFolderId ?? null;
|
||||
}
|
||||
if (patch.color !== undefined) body.color = patch.color ?? "";
|
||||
if (patch.icon !== undefined) body.icon = patch.icon ?? "";
|
||||
const response = await apiClient.patch<ServerFolder>(
|
||||
`/api/v1/storage/folders/${id}`,
|
||||
body,
|
||||
);
|
||||
return toFolderRecord(response.data);
|
||||
},
|
||||
|
||||
async delete(id: FolderId): Promise<FolderId[]> {
|
||||
const response = await apiClient.delete<{ removedFolderIds: string[] }>(
|
||||
`/api/v1/storage/folders/${id}`,
|
||||
);
|
||||
// Validate at the trust boundary - same posture as toFolderRecord.
|
||||
return (response.data?.removedFolderIds ?? []).map(parseFolderId);
|
||||
},
|
||||
|
||||
async moveFileToFolder(
|
||||
fileRemoteId: number,
|
||||
folderId: FolderId | null,
|
||||
): Promise<void> {
|
||||
await apiClient.patch(`/api/v1/storage/files/${fileRemoteId}/folder`, {
|
||||
folderId,
|
||||
});
|
||||
},
|
||||
|
||||
async bulkMoveFiles(
|
||||
fileRemoteIds: number[],
|
||||
folderId: FolderId | null,
|
||||
): Promise<{ movedFileIds: number[]; skippedFileIds: number[] }> {
|
||||
const response = await apiClient.patch<{
|
||||
movedFileIds: number[];
|
||||
skippedFileIds: number[];
|
||||
}>("/api/v1/storage/files/folder", {
|
||||
folderId,
|
||||
fileIds: fileRemoteIds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, test, beforeEach } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
import {
|
||||
DATABASE_CONFIGS,
|
||||
indexedDBManager,
|
||||
} from "@app/services/indexedDBManager";
|
||||
|
||||
/**
|
||||
* Regression test for the IDB v2->v4 migration silent-clobber bug fixed in
|
||||
* commit 57e056026 "Merge IDB migrations". Pre-fix: two separate cursor
|
||||
* walks (v2->v3 and v3->v4) ran in the same versionchange transaction;
|
||||
* the second cursor's `value` was a structured-clone snapshot taken
|
||||
* BEFORE the first cursor's update() committed, so the v4 walk wrote
|
||||
* back its pre-v3 snapshot and silently erased every v3 field on every
|
||||
* row (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory).
|
||||
*
|
||||
* A future v5 migration written as a third separate cursor walk would
|
||||
* re-introduce the exact same failure mode for anyone jumping multiple
|
||||
* versions. This test pins the behaviour by seeding a v2 DB and asserting
|
||||
* every v3+v4 field is present and correctly set after the upgrade.
|
||||
*/
|
||||
|
||||
const DB_NAME = DATABASE_CONFIGS.FILES.name;
|
||||
const TARGET_VERSION = DATABASE_CONFIGS.FILES.version;
|
||||
|
||||
/**
|
||||
* Open a v2-shaped FILES DB and seed it with two v2-shape records. v2
|
||||
* had only the `files` store with `id` as keyPath; none of the v3 fields
|
||||
* (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory) or
|
||||
* the v4 field (folderId) yet existed on records.
|
||||
*/
|
||||
function seedV2Database(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 2);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
store.add({
|
||||
id: "file-a",
|
||||
name: "alpha.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 1000,
|
||||
data: new Blob(["hello"], { type: "application/pdf" }),
|
||||
});
|
||||
store.add({
|
||||
id: "file-b",
|
||||
name: "beta.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2048,
|
||||
lastModified: 2000,
|
||||
data: new Blob(["world"], { type: "application/pdf" }),
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("seed open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v3-shape DB (so we can also verify that a v3->v4 jump still
|
||||
* lands correctly under the merged migration).
|
||||
*/
|
||||
function seedV3Database(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 3);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
store.add({
|
||||
id: "file-c",
|
||||
name: "gamma.pdf",
|
||||
type: "application/pdf",
|
||||
size: 4096,
|
||||
lastModified: 3000,
|
||||
data: new Blob(["v3"], { type: "application/pdf" }),
|
||||
// v3 fields, set explicitly so this is genuinely a v3 record
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: "file-c",
|
||||
parentFileId: undefined,
|
||||
toolHistory: [],
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("v3 seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("v3 seed open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
function readAllFiles(): Promise<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readonly");
|
||||
const store = tx.objectStore("files");
|
||||
const all = store.getAll();
|
||||
all.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(all.result);
|
||||
};
|
||||
all.onerror = () => reject(all.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
indexedDBManager.closeAllDatabases();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => resolve(); // best-effort in jsdom
|
||||
});
|
||||
}
|
||||
|
||||
describe("IndexedDB migration (FILES store)", () => {
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("v2 -> latest applies v3 AND v4 fields without clobbering", async () => {
|
||||
await seedV2Database();
|
||||
// Trigger the migration via the production code path.
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
for (const row of rows) {
|
||||
// v3 fields - this is the critical assertion. Pre-fix, the v4
|
||||
// cursor walk's structured-clone snapshot would write these back
|
||||
// as undefined, silently erasing them.
|
||||
expect(row.isLeaf).toBe(true);
|
||||
expect(row.versionNumber).toBe(1);
|
||||
expect(row.originalFileId).toBe(row.id);
|
||||
expect(row.toolHistory).toEqual([]);
|
||||
// parentFileId is intentionally set to undefined for legacy records
|
||||
// (they're version roots, not children of anything).
|
||||
expect(row.parentFileId).toBeUndefined();
|
||||
// v4 field
|
||||
expect(row.folderId).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("v3 -> latest applies v4 folderId without erasing existing v3 fields", async () => {
|
||||
await seedV3Database();
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0]!;
|
||||
|
||||
// Existing v3 fields should be unchanged
|
||||
expect(row.isLeaf).toBe(true);
|
||||
expect(row.versionNumber).toBe(1);
|
||||
expect(row.originalFileId).toBe("file-c");
|
||||
expect(row.toolHistory).toEqual([]);
|
||||
// v4 field added
|
||||
expect(row.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test("fresh install at latest version requires no migration", async () => {
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
|
||||
TARGET_VERSION,
|
||||
);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ class IndexedDBManager {
|
||||
storeConfig.name === "files" &&
|
||||
store
|
||||
) {
|
||||
this.migrateFileHistoryFields(store, oldVersion);
|
||||
this.migrateFilesStore(store, oldVersion);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -155,78 +155,97 @@ class IndexedDBManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing file records to include new file history fields
|
||||
* Single-pass migration for the `files` store on stirling-pdf-files.
|
||||
*
|
||||
* Runs ONE openCursor() walk and applies every applicable per-version
|
||||
* delta to each record before `cursor.update()` writes it back. The
|
||||
* previous design called migrateFileHistoryFields and migrateFolderField
|
||||
* as two separate cursor walks inside the same onupgradeneeded
|
||||
* transaction; their requests interleaved in the IDB request queue so
|
||||
* the second walk's `cursor.value` was a stale snapshot taken before
|
||||
* the first walk's `update()` had been processed - the second
|
||||
* `update()` then wrote that stale object back, silently erasing
|
||||
* isLeaf / versionNumber / originalFileId / parentFileId / toolHistory
|
||||
* on every row both cursors touched. Folding into one cursor + one
|
||||
* write per record removes the race entirely.
|
||||
*
|
||||
* New per-version blocks should be added as additional
|
||||
* `if (oldVersion < N) { ... }` sections below.
|
||||
*/
|
||||
private migrateFileHistoryFields(
|
||||
store: IDBObjectStore,
|
||||
oldVersion: number,
|
||||
): void {
|
||||
// Only migrate if upgrading from a version before file history was added (version < 3)
|
||||
if (oldVersion >= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting file history migration for existing records...");
|
||||
private migrateFilesStore(store: IDBObjectStore, oldVersion: number): void {
|
||||
if (oldVersion >= 4) return; // nothing to migrate at the current schema
|
||||
|
||||
const cursor = store.openCursor();
|
||||
let migratedCount = 0;
|
||||
let migrated = 0;
|
||||
|
||||
cursor.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const record = cursor.value;
|
||||
let needsUpdate = false;
|
||||
|
||||
// Add missing file history fields with sensible defaults
|
||||
if (record.isLeaf === undefined) {
|
||||
record.isLeaf = true; // Existing files are unprocessed, should appear in recent files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.versionNumber === undefined) {
|
||||
record.versionNumber = 1; // Existing files are first version
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.originalFileId === undefined) {
|
||||
record.originalFileId = record.id; // Existing files are their own root
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.parentFileId === undefined) {
|
||||
record.parentFileId = undefined; // No parent for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (record.toolHistory === undefined) {
|
||||
record.toolHistory = []; // No history for existing files
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Update the record if any fields were missing
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
cursor.update(record);
|
||||
migratedCount++;
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate record:", record.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Migration complete
|
||||
console.log(
|
||||
`File history migration completed. Migrated ${migratedCount} records.`,
|
||||
);
|
||||
const result = (event.target as IDBRequest)
|
||||
.result as IDBCursorWithValue | null;
|
||||
if (!result) {
|
||||
console.log(`Files-store migration complete (${migrated} records).`);
|
||||
return;
|
||||
}
|
||||
const record = result.value;
|
||||
let needsUpdate = false;
|
||||
|
||||
// v3: file history fields. Sensible defaults so existing files keep
|
||||
// showing up in the recent view and act as their own version root.
|
||||
if (oldVersion < 3) {
|
||||
if (record.isLeaf === undefined) {
|
||||
record.isLeaf = true;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.versionNumber === undefined) {
|
||||
record.versionNumber = 1;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.originalFileId === undefined) {
|
||||
record.originalFileId = record.id;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.parentFileId === undefined) {
|
||||
record.parentFileId = undefined;
|
||||
needsUpdate = true;
|
||||
}
|
||||
if (record.toolHistory === undefined) {
|
||||
record.toolHistory = [];
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// v4: folderId. Required to exist on every row so the folderId
|
||||
// index doesn't drop the record out of bounded-key cursor scans.
|
||||
if (oldVersion < 4 && record.folderId === undefined) {
|
||||
record.folderId = null;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
result.update(record);
|
||||
migrated += 1;
|
||||
} catch (error) {
|
||||
// Aborting the upgrade transaction here forces IndexedDB to roll back
|
||||
// the schema version bump too - the user retries on next page load
|
||||
// instead of silently losing folderId / isLeaf / etc on partial rows.
|
||||
console.error("Failed to migrate record:", record.id, error);
|
||||
store.transaction.abort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
result.continue();
|
||||
};
|
||||
|
||||
cursor.onerror = (event) => {
|
||||
console.error(
|
||||
"File history migration failed:",
|
||||
(event.target as IDBRequest).error,
|
||||
);
|
||||
// Same reasoning as the per-record catch above: abort the upgrade so the
|
||||
// schema doesn't get marked as v4 with rows still on the v3 shape.
|
||||
const err = (event.target as IDBRequest).error;
|
||||
console.error("Files-store migration cursor failed:", err);
|
||||
try {
|
||||
store.transaction.abort();
|
||||
} catch {
|
||||
// Already aborted - ignore.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,7 +324,7 @@ class IndexedDBManager {
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: "stirling-pdf-files",
|
||||
version: 3,
|
||||
version: 4,
|
||||
stores: [
|
||||
{
|
||||
name: "files",
|
||||
@@ -316,6 +335,20 @@ export const DATABASE_CONFIGS = {
|
||||
{ name: "originalFileId", keyPath: "originalFileId", unique: false },
|
||||
{ name: "parentFileId", keyPath: "parentFileId", unique: false },
|
||||
{ name: "versionNumber", keyPath: "versionNumber", unique: false },
|
||||
{ name: "folderId", keyPath: "folderId", unique: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "folders",
|
||||
keyPath: "id",
|
||||
indexes: [
|
||||
{
|
||||
name: "parentFolderId",
|
||||
keyPath: "parentFolderId",
|
||||
unique: false,
|
||||
},
|
||||
{ name: "name", keyPath: "name", unique: false },
|
||||
{ name: "createdAt", keyPath: "createdAt", unique: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -37,28 +37,30 @@ export async function waitForModalClose(
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one or more files through the workbench's "Files" modal. The modal
|
||||
* auto-closes once a file is selected; we wait for the overlay to vanish so
|
||||
* the caller can interact with the page immediately afterwards.
|
||||
* Upload one or more files through the FileSidebar's "Open from computer"
|
||||
* action. The button is always rendered (collapsed or expanded sidebar) and
|
||||
* triggers the hidden `data-testid="file-input"` native picker directly -
|
||||
* there is no modal to wait for under the post-refactor design.
|
||||
*
|
||||
* Pass `awaitClose: false` when the spec is testing a flow that keeps the
|
||||
* modal open after upload (e.g. encrypted-PDF unlock — the unlock modal
|
||||
* appears on top before the files modal closes).
|
||||
* `setInputFiles` doesn't await the input's async onChange (which writes to
|
||||
* IndexedDB via `addFiles`), so without a sync point a caller that follows
|
||||
* with `page.goto()` can race the IDB flush. Wait for the workbench to
|
||||
* pick up the upload (the FileSidebar renders the added file in its scroll
|
||||
* list once `addFiles` resolves and IDB has been written).
|
||||
*/
|
||||
export async function uploadFiles(
|
||||
page: Page,
|
||||
filePaths: string | string[],
|
||||
opts: { awaitClose?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { awaitClose = true } = opts;
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
await page.getByTestId("files-button").click();
|
||||
await waitForModalOpen(page);
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
.setInputFiles(filePaths as string | string[]);
|
||||
if (awaitClose) {
|
||||
await waitForModalClose(page);
|
||||
}
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(paths);
|
||||
// Sync point: wait until at least one file lands in the sidebar's file
|
||||
// list. The list only renders once `addFiles` has resolved (which awaits
|
||||
// the IDB write). Use first() so multi-file uploads pass too.
|
||||
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { loginAndSetup } from "@app/tests/helpers/login";
|
||||
import {
|
||||
switchToEditorIfViewerMode,
|
||||
runToolAndWaitForReview,
|
||||
waitForModalOpen,
|
||||
waitForModalClose,
|
||||
} from "@app/tests/helpers/ui-helpers";
|
||||
import path from "path";
|
||||
@@ -33,19 +32,18 @@ test.describe("Encrypted PDF: unlock then merge", () => {
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
await page.getByTestId("files-button").click();
|
||||
await waitForModalOpen(page);
|
||||
await page
|
||||
.locator('[data-testid="file-input"]')
|
||||
.setInputFiles([ENCRYPTED_PDF, SAMPLE_PDF]);
|
||||
|
||||
// Encrypted unlock modal appears (the files modal may still be closing)
|
||||
// Encrypted unlock modal appears once decryption is attempted.
|
||||
const passwordInput = page.getByPlaceholder(/password/i).first();
|
||||
if (
|
||||
!(await passwordInput.isVisible({ timeout: 10_000 }).catch(() => false))
|
||||
) {
|
||||
test.skip(
|
||||
true,
|
||||
"Encrypted unlock modal not surfaced — fixture may not match this build",
|
||||
"Encrypted unlock modal not surfaced - fixture may not match this build",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* End-to-End Tests for Convert Tool
|
||||
*
|
||||
* All backend API calls are mocked via page.route() — no real backend required.
|
||||
* All backend API calls are mocked via page.route() - no real backend required.
|
||||
* The Vite dev server must be running (handled by playwright.config.ts webServer).
|
||||
*/
|
||||
|
||||
@@ -23,22 +23,14 @@ async function dismissTourTooltip(page: Page) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: upload a file through the Files modal
|
||||
// Uses the HiddenFileInput (data-testid="file-input") which has the correct
|
||||
// onChange handler. Waits for the modal to auto-close after upload.
|
||||
// Helper: upload a file via the FileSidebar's "Open from computer" action.
|
||||
// The button now triggers the native OS picker directly - no modal - and
|
||||
// the hidden `data-testid="file-input"` accepts `setInputFiles` in either
|
||||
// sidebar state.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function uploadFile(page: Page, filePath: string) {
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
|
||||
// Modal auto-closes after file is selected
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "hidden",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,12 +142,12 @@ test.describe("Convert Tool", () => {
|
||||
await uploadFile(page, SAMPLE_PDF);
|
||||
await navigateToConvert(page);
|
||||
|
||||
// Before selecting TO format — button visible but disabled
|
||||
// Before selecting TO format - button visible but disabled
|
||||
const convertBtn = page.getByTestId("convert-button");
|
||||
await expect(convertBtn).toBeVisible({ timeout: 3000 });
|
||||
await expect(convertBtn).toBeDisabled();
|
||||
|
||||
// After selecting PNG as TO format — button enabled
|
||||
// After selecting PNG as TO format - button enabled
|
||||
await selectToFormat(page, "png");
|
||||
await expect(convertBtn).toBeEnabled({ timeout: 3000 });
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* End-to-End Tests for Encrypted PDF Password Prompting
|
||||
*
|
||||
* Tests the EncryptedPdfUnlockModal flow when uploading password-protected PDFs.
|
||||
* All backend API calls are mocked via page.route() — no real backend required.
|
||||
* All backend API calls are mocked via page.route() - no real backend required.
|
||||
*
|
||||
* Coverage trimmed to 5 high-value cases:
|
||||
* 1. Modal renders with the expected title/inputs/buttons.
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Removed previously: input-disabled-when-empty, input-enabled-after-fill,
|
||||
* skip-button-closes, normal-PDF-doesn't-prompt, single-file-hides-use-for-all,
|
||||
* unlock-all-wrong-password — all transitively covered or low-value.
|
||||
* unlock-all-wrong-password - all transitively covered or low-value.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
@@ -64,10 +64,7 @@ function mockRemovePasswordWrongPassword(page: Page) {
|
||||
|
||||
async function uploadEncryptedFile(page: Page, filePath: string) {
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
// No modal flow - `files-button` triggers the native picker directly.
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
|
||||
}
|
||||
|
||||
@@ -157,10 +154,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
|
||||
await mockRemovePasswordSuccess(page);
|
||||
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
// No modal flow - `files-button` triggers the native picker directly.
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles([
|
||||
{
|
||||
name: "encrypted-a.pdf",
|
||||
@@ -179,7 +173,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
|
||||
// detected as encrypted. PDF.js encryption probing runs per-file and
|
||||
// can lag the modal opening (which fires as soon as the first file
|
||||
// surfaces a password prompt). A 10s timeout was occasionally too tight
|
||||
// on heavily-loaded CI runners — bump to 20s.
|
||||
// on heavily-loaded CI runners - bump to 20s.
|
||||
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
|
||||
await expect(unlockAllBtn).toBeVisible({ timeout: 20000 });
|
||||
|
||||
|
||||
@@ -16,19 +16,18 @@ test.describe("File state persists across tool navigation", () => {
|
||||
}) => {
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Sanity: the file picker now lists the upload
|
||||
await page.getByTestId("files-button").click();
|
||||
// Sanity: My Files page lists the upload
|
||||
await page.getByTestId("my-files-button").click();
|
||||
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Navigate to /split
|
||||
await page.goto("/split");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Re-open the files modal — sample.pdf must still be there
|
||||
await page.getByTestId("files-button").click();
|
||||
// Re-open My Files - sample.pdf must still be there (persisted across tools)
|
||||
await page.getByTestId("my-files-button").click();
|
||||
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
/** Screenshot review of /files surfaces; dumps PNGs to screenshots/files-page. */
|
||||
|
||||
interface SeedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
remoteStorageId: number | null;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
|
||||
await page.addInitScript((records) => {
|
||||
const open = window.indexedDB.open("stirling-pdf-files", 4);
|
||||
open.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
// Create both `files` and `folders` stores on this DB.
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
const store = db.createObjectStore("files", { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("folderId", "folderId", { unique: false });
|
||||
store.createIndex("originalFileId", "originalFileId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folders")) {
|
||||
const fStore = db.createObjectStore("folders", { keyPath: "id" });
|
||||
fStore.createIndex("parentFolderId", "parentFolderId", {
|
||||
unique: false,
|
||||
});
|
||||
fStore.createIndex("name", "name", { unique: false });
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
const now = Date.now();
|
||||
for (const f of records) {
|
||||
store.put({
|
||||
id: f.id,
|
||||
fileId: f.id,
|
||||
quickKey: f.id,
|
||||
name: f.name,
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: now,
|
||||
createdAt: now,
|
||||
data: new ArrayBuffer(8),
|
||||
thumbnail: null,
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: f.id,
|
||||
parentFileId: null,
|
||||
toolHistory: [],
|
||||
folderId: f.folderId ?? null,
|
||||
remoteStorageId: f.remoteStorageId,
|
||||
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
|
||||
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
|
||||
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
|
||||
remoteAccessRole: f.remoteStorageId ? "owner" : null,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, files);
|
||||
}
|
||||
|
||||
async function stubStorageApis(
|
||||
page: Page,
|
||||
opts: { storageEnabled?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { storageEnabled = true } = opts;
|
||||
const configPayload = {
|
||||
appVersion: "test",
|
||||
storageEnabled,
|
||||
storageSharingEnabled: false,
|
||||
storageShareLinksEnabled: false,
|
||||
};
|
||||
await page.route("**/api/v1/config/app-config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/folders", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/**", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
const SCREENSHOTS_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
"screenshots",
|
||||
"files-page",
|
||||
);
|
||||
|
||||
function shotPath(name: string): string {
|
||||
return path.join(SCREENSHOTS_DIR, `${name}.png`);
|
||||
}
|
||||
|
||||
async function settle(page: Page, ms = 350): Promise<void> {
|
||||
// Let Mantine portal transitions settle.
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
test.describe("Files page screenshots", () => {
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
|
||||
|
||||
test("01_empty_state_ctas", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("01_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("02_empty_state_storage_off", async ({ page }) => {
|
||||
await stubStorageApis(page, { storageEnabled: false });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("02_empty_state_storage_off") });
|
||||
});
|
||||
|
||||
test("03_subtoolbar_with_files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("03_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("04_kebab_save_to_server_local", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("04_kebab_save_to_server_local") });
|
||||
});
|
||||
|
||||
test("05_kebab_no_save_to_server_cloud", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-c.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("05_kebab_no_save_to_server_cloud"),
|
||||
});
|
||||
});
|
||||
|
||||
test("06_details_panel_save_to_server", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("06_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("07_move_dialog_collapsed", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Move to folder/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("07_move_dialog_collapsed") });
|
||||
});
|
||||
|
||||
test("08_move_dialog_create_folder_expanded", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Move to folder/i }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("08_move_dialog_create_folder_expanded"),
|
||||
});
|
||||
});
|
||||
|
||||
test("09_subtoolbar_narrow_viewport", async ({ page, browserName }) => {
|
||||
test.skip(browserName !== "chromium", "viewport-resize spec");
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("09_subtoolbar_narrow_viewport") });
|
||||
});
|
||||
|
||||
test("08b_move_dialog_after_create_folder", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.route(
|
||||
"**/api/v1/storage/folders",
|
||||
async (route: Route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
// FolderId must be a UUID; timestamps must be ISO strings.
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "11111111-2222-4333-8444-555555555555",
|
||||
name: "Reports",
|
||||
parentFolderId: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: [] });
|
||||
},
|
||||
{ times: 5 },
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: /New folder name/i })
|
||||
.fill("Reports");
|
||||
await page.getByRole("button", { name: /^Create$/i }).click();
|
||||
// Inline row collapses back; create succeeded.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Create new folder/i }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("08b_move_dialog_after_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode pass ─────────────────────────────────────────────────────
|
||||
async function enableDarkMode(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("mantine-color-scheme", "dark");
|
||||
localStorage.setItem("mantine-color-scheme-value", "dark");
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
}
|
||||
|
||||
test("11_dark_empty_state_ctas", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("11_dark_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("12_dark_subtoolbar_with_files", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("12_dark_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("13_dark_move_dialog_create_folder", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("13_dark_move_dialog_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RTL pass ────────────────────────────────────────────────────────────
|
||||
async function enableRtl(page: Page): Promise<void> {
|
||||
// Seed language + dir before first paint.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("i18nextLng", "ar-AR");
|
||||
localStorage.setItem("stirling-language", "ar-AR");
|
||||
localStorage.setItem("stirling-language-source", "user");
|
||||
document.documentElement.setAttribute("dir", "rtl");
|
||||
document.documentElement.setAttribute("lang", "ar-AR");
|
||||
});
|
||||
}
|
||||
|
||||
test("15_rtl_empty_state_ctas", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("15_rtl_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("16_rtl_subtoolbar_with_files", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("16_rtl_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("17_rtl_move_dialog_create_folder", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("17_rtl_move_dialog_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
test("18_rtl_details_panel_save_to_server", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("18_rtl_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("14_dark_details_panel_save_to_server", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("14_dark_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("19_delete_folder_dialog", async ({ page }) => {
|
||||
const FOLDER_ID = "22222222-2222-4333-8444-555555555555";
|
||||
await stubStorageApis(page);
|
||||
// Seed a file inside the Reports folder so the checkbox appears.
|
||||
await seedFiles(page, [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha.pdf",
|
||||
remoteStorageId: 9001,
|
||||
folderId: FOLDER_ID,
|
||||
},
|
||||
{
|
||||
id: "bravo",
|
||||
name: "bravo.pdf",
|
||||
remoteStorageId: 9002,
|
||||
folderId: FOLDER_ID,
|
||||
},
|
||||
]);
|
||||
await page.route("**/api/v1/storage/folders", async (route: Route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: FOLDER_ID,
|
||||
name: "Reports",
|
||||
parentFolderId: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the Reports folder card or list row.
|
||||
await expect(page.getByText("Reports").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Open the kebab on the folder card.
|
||||
const folderCard = page
|
||||
.locator(".files-page-card.is-folder")
|
||||
.filter({ hasText: "Reports" })
|
||||
.first();
|
||||
await folderCard.getByRole("button", { name: /Folder actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Delete folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Delete folder\?/i }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("19_delete_folder_dialog") });
|
||||
});
|
||||
|
||||
test("10_subtoolbar_phone_hidden", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("10_subtoolbar_phone_hidden") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,819 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
/** Stubbed coverage for the /files page UI invariants. */
|
||||
|
||||
interface SeedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
remoteStorageId: number | null;
|
||||
versionNumber?: number;
|
||||
toolHistory?: Array<{ toolId: string; timestamp: number }>;
|
||||
}
|
||||
|
||||
/** Seed IDB + register the cloud entries with the server stub. */
|
||||
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
|
||||
// Build the server-side view from the cloud entries so reconcileServerFiles
|
||||
// sees them as still-existing on the server (otherwise they get detached).
|
||||
const serverFiles = files
|
||||
.filter((f) => f.remoteStorageId != null)
|
||||
.map((f) => ({
|
||||
id: f.remoteStorageId,
|
||||
fileName: f.name,
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
}));
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({ json: serverFiles }),
|
||||
);
|
||||
await page.addInitScript((records) => {
|
||||
const open = window.indexedDB.open("stirling-pdf-files", 4);
|
||||
open.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
// Create both `files` and `folders` stores on this DB.
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
const store = db.createObjectStore("files", { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("folderId", "folderId", { unique: false });
|
||||
store.createIndex("originalFileId", "originalFileId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folders")) {
|
||||
const fStore = db.createObjectStore("folders", { keyPath: "id" });
|
||||
fStore.createIndex("parentFolderId", "parentFolderId", {
|
||||
unique: false,
|
||||
});
|
||||
fStore.createIndex("name", "name", { unique: false });
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
const now = Date.now();
|
||||
for (const f of records) {
|
||||
store.put({
|
||||
id: f.id,
|
||||
fileId: f.id,
|
||||
quickKey: f.id,
|
||||
name: f.name,
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: now,
|
||||
createdAt: now,
|
||||
// Placeholder; opening would need real bytes.
|
||||
data: new ArrayBuffer(8),
|
||||
thumbnail: null,
|
||||
isLeaf: true,
|
||||
versionNumber: f.versionNumber ?? 1,
|
||||
originalFileId: f.id,
|
||||
parentFileId: null,
|
||||
toolHistory: f.toolHistory ?? [],
|
||||
folderId: null,
|
||||
remoteStorageId: f.remoteStorageId,
|
||||
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
|
||||
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
|
||||
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
|
||||
remoteAccessRole: f.remoteStorageId ? "owner" : null,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, files);
|
||||
}
|
||||
|
||||
/** Stub the storage + config endpoints hit on mount. */
|
||||
async function stubStorageApis(
|
||||
page: Page,
|
||||
opts: { storageEnabled?: boolean; sharingEnabled?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { storageEnabled = true, sharingEnabled = false } = opts;
|
||||
// No enableLogin; setting it would trigger the auth redirect.
|
||||
const configPayload = {
|
||||
appVersion: "test",
|
||||
storageEnabled,
|
||||
storageSharingEnabled: sharingEnabled,
|
||||
storageShareLinksEnabled: sharingEnabled,
|
||||
};
|
||||
await page.route("**/api/v1/config/app-config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/folders", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/**", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate to /files and wait for at least one seeded card. */
|
||||
async function gotoFilesPage(page: Page): Promise<void> {
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Files page", () => {
|
||||
test.describe("Selection model", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
{ id: "charlie", name: "charlie.pdf", remoteStorageId: null },
|
||||
{ id: "delta", name: "delta.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("plain click selects one file (single-select replaces)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
await expect(cards.locator(".is-selected")).toHaveCount(0);
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
|
||||
|
||||
// Plain-clicking a different file replaces the selection.
|
||||
await cards.nth(1).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("ctrl+click toggles into multi-select mode (sticky)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
|
||||
// In multi-select (2+), plain-click ADDS instead of replacing.
|
||||
await cards.nth(2).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(3);
|
||||
|
||||
// Plain-click an already-selected file in multi-mode removes it.
|
||||
await cards.nth(0).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("checkboxes hidden in single-select, visible in multi-select", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
// 0 selected: no checkboxes anywhere on file cards.
|
||||
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
|
||||
|
||||
// 1 selected: still no checkbox (highlight border is the indicator).
|
||||
await cards.nth(0).click();
|
||||
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
|
||||
|
||||
// 2+ selected: checkboxes appear on every file card.
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(
|
||||
page.locator(".files-page-card-selector").first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Select all tooltip explains Ctrl/Shift shortcuts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Tooltip is the discovery point for Ctrl/Shift multi-select.
|
||||
const selectAll = page.getByRole("button", { name: /^Select all$/i });
|
||||
await selectAll.hover();
|
||||
await expect(
|
||||
page.getByText(/hold Ctrl.*Cmd.*Shift to select a range/i),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Bulk action button visibility", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
|
||||
{ id: "local-b", name: "local-b.pdf", remoteStorageId: null },
|
||||
{ id: "cloud-a", name: "cloud-a.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Save to server hidden when nothing selected", async ({ page }) => {
|
||||
await gotoFilesPage(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Save to server visible when local file selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Click the local-a card.
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" })
|
||||
.click();
|
||||
// Two entry points share the name; use .first() for strict mode.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("Save to server hidden when ONLY cloud files selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Cloud-only selection - nothing to save (already on server).
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-a.pdf" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Per-file kebab has Save to server item for local file", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Open the kebab without first selecting.
|
||||
const localCard = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" });
|
||||
await localCard.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Per-file kebab hides Save to server for cloud file", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Cloud file kebab omits Save to server.
|
||||
const cloudCard = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-a.pdf" });
|
||||
await cloudCard.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Upload behaviour", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "seed", name: "seed.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("upload on /files page doesn't navigate the user away", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Write to the hidden file input directly.
|
||||
const tinyPdf = Buffer.from("%PDF-1.4\n%%EOF", "utf8");
|
||||
const input = page.locator('input[data-testid="file-input"]').first();
|
||||
if ((await input.count()) === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"No file-input testid on this build - upload entry-point selector drifted",
|
||||
);
|
||||
}
|
||||
await input.setInputFiles({
|
||||
name: "upload-test.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: tinyPdf,
|
||||
});
|
||||
// Upload must leave the user on /files.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Already-active file handling", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "active-test", name: "active-test.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Add to workspace on already-active file navigates without crash", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "active-test.pdf" });
|
||||
await card.click();
|
||||
// First Add to workspace; routes to viewer.
|
||||
await page
|
||||
.getByRole("button", { name: /Add to workspace/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
|
||||
|
||||
// Re-add the now-active file; activation branches on requested stubs.
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card2 = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "active-test.pdf" });
|
||||
await expect(card2).toBeVisible({ timeout: 5_000 });
|
||||
await card2.click();
|
||||
await page
|
||||
.getByRole("button", { name: /Add to workspace/i })
|
||||
.first()
|
||||
.click();
|
||||
// Should still navigate away, NOT throw and leave us stuck on /files.
|
||||
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Drag-and-drop wiring", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "drag-test", name: "drag-test.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("card thumbnail <img> is not natively draggable", async ({ page }) => {
|
||||
// draggable={false} keeps the card's onDragStart as drag authority.
|
||||
await gotoFilesPage(page);
|
||||
const thumbImg = page.locator(".files-page-card-thumb img").first();
|
||||
if ((await thumbImg.count()) === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"Seeded files have no thumbnailUrl so the <img> branch isn't rendered - drag-hijack regression can't surface",
|
||||
);
|
||||
}
|
||||
await expect(thumbImg).toHaveAttribute("draggable", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile details drawer", () => {
|
||||
test.use({
|
||||
autoGoto: false,
|
||||
viewport: { width: 500, height: 900 },
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "phone-a", name: "phone-a.pdf", remoteStorageId: null },
|
||||
{ id: "phone-b", name: "phone-b.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("drawer does NOT auto-open on file selection", async ({ page }) => {
|
||||
// Drawer is button-triggered only.
|
||||
await gotoFilesPage(page);
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "phone-a.pdf" })
|
||||
.click();
|
||||
// No drawer overlay should be present.
|
||||
await expect(page.locator(".mantine-Drawer-content")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Show details button opens drawer with file info", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "phone-a.pdf" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: /Show details/i }).click();
|
||||
// Drawer opens, file name shown inside it.
|
||||
await expect(page.locator(".mantine-Drawer-content")).toBeVisible({
|
||||
timeout: 3_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator(".mantine-Drawer-content").getByText("phone-a.pdf"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("multi-select still works while drawer is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
// Drawer stays closed so the second click reaches the card.
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Empty-state CTAs", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("renders Upload + Create folder CTAs when grid is empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
// No seedFiles - grid is empty so EmptyState renders.
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the empty state itself rather than card visibility -
|
||||
// gotoFilesPage's card-visibility wait would time out here.
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Both CTAs centered in the grid area where the eye lands.
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Upload files/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Create folder/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create folder CTA disabled when storage isn't reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Storage disabled - the New folder action is gated and the CTA
|
||||
// should mirror that gating with a disabled state.
|
||||
await stubStorageApis(page, { storageEnabled: false });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const createCta = page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Create folder/i });
|
||||
await expect(createCta).toBeVisible();
|
||||
await expect(createCta).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Move dialog inline create-folder", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Move dialog shows Create new folder affordance", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "to-move", name: "to-move.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Open the move dialog via the per-file kebab.
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "to-move.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Create new folder/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Side-rail integration with /files", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Rail Search focuses the central search field, no navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Click the search row in the rail.
|
||||
await page.locator(".file-sidebar-search-row").click();
|
||||
// The central search input should be focused.
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
expect(focused).toMatch(/Search/i);
|
||||
// And we must still be on /files (i.e. didn't navigate home).
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test("Rail New folder button visible on /files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// The extra action is the only thing with this testid.
|
||||
await expect(
|
||||
page.locator('[data-testid="files-rail-new-folder"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Server file sync", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Server-only file downloads bytes when opened", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
const REMOTE_ID = 9001;
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: REMOTE_ID,
|
||||
fileName: "cross-browser.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 4096,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let downloadHit = false;
|
||||
await page.route(
|
||||
`**/api/v1/storage/files/${REMOTE_ID}/download`,
|
||||
(route: Route) => {
|
||||
downloadHit = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition": 'attachment; filename="cross-browser.pdf"',
|
||||
},
|
||||
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cross-browser.pdf" });
|
||||
await expect(card).toBeVisible({ timeout: 5_000 });
|
||||
// Open via Add to workspace (kebab > Add to workspace).
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
|
||||
// The materializer should have hit the download endpoint and
|
||||
// routed the user to the viewer (/).
|
||||
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(downloadHit).toBe(true);
|
||||
});
|
||||
|
||||
test("Shared-link file appears in /files and materializes on open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page, { sharingEnabled: true });
|
||||
const SHARE_TOKEN = "tok-abc-123";
|
||||
// Owner-side listing has no entry for the shared file.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route(
|
||||
"**/api/v1/storage/share-links/accessed",
|
||||
(route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
shareToken: SHARE_TOKEN,
|
||||
fileId: 4242,
|
||||
fileName: "shared-report.pdf",
|
||||
owner: "alice",
|
||||
ownedByCurrentUser: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let shareDownloadHit = false;
|
||||
await page.route(
|
||||
`**/api/v1/storage/share-links/${SHARE_TOKEN}`,
|
||||
(route: Route) => {
|
||||
shareDownloadHit = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition": 'attachment; filename="shared-report.pdf"',
|
||||
},
|
||||
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "shared-report.pdf" });
|
||||
await expect(card).toBeVisible({ timeout: 5_000 });
|
||||
// Open the card and confirm the share-link download endpoint fires.
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
|
||||
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(shareDownloadHit).toBe(true);
|
||||
});
|
||||
|
||||
test("Server-only files appear in /files on a fresh browser", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
// No local IDB seed. Override the GET /api/v1/storage/files route
|
||||
// to return a file that the server knows about. The /files grid
|
||||
// should pull this in via the new sync path.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 9001,
|
||||
fileName: "cross-browser.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 4096,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// The file lands as a synthesised server stub.
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cross-browser.pdf" }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Shared-by-me tab lists only files I own with share links", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page, { sharingEnabled: true });
|
||||
// Three server files: one shared via link (owned by me), one shared
|
||||
// with users (owned by me), one plain mine, and one owned by someone else.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 1,
|
||||
fileName: "link-shared.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [{ token: "tok1" }],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: "user-shared.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
sharedUsers: [{ username: "bob" }],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: "plain-mine.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: "from-someone-else.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "alice",
|
||||
ownedByCurrentUser: false,
|
||||
accessRole: "viewer",
|
||||
shareLinks: [],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the 4 cards to land via server sync.
|
||||
await expect(
|
||||
page.locator(".files-page-card:not(.is-folder)"),
|
||||
).toHaveCount(4, { timeout: 5_000 });
|
||||
|
||||
// "Shared by me" -> only link-shared.pdf
|
||||
await page.locator("#filesPage-tab-sharedByMe").click();
|
||||
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
|
||||
|
||||
// "I'm sharing" -> only user-shared.pdf
|
||||
await page.locator("#filesPage-tab-imSharing").click();
|
||||
const imSharingCards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await expect(imSharingCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(imSharingCards.first()).toContainText("user-shared.pdf");
|
||||
|
||||
// "Shared with me" -> only from-someone-else.pdf
|
||||
await page.locator("#filesPage-tab-shared").click();
|
||||
const sharedWithMeCards = page.locator(
|
||||
".files-page-card:not(.is-folder)",
|
||||
);
|
||||
await expect(sharedWithMeCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(sharedWithMeCards.first()).toContainText(
|
||||
"from-someone-else.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folder tree panel resize", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Resize handle is present and keyboard-adjustable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
const handle = page.locator(".folder-tree-panel-resizer").first();
|
||||
await expect(handle).toBeVisible();
|
||||
const before = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
await handle.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const after = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
// Four 8px steps = +32px.
|
||||
expect(after).toBeGreaterThanOrEqual(before + 24);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,24 +9,17 @@ const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
* and accepts a query catches the most damaging regression (search bar
|
||||
* disappears when reader refactors).
|
||||
*/
|
||||
test.describe("Reader — in-document text search", () => {
|
||||
test.describe("Reader - in-document text search", () => {
|
||||
test("search input is reachable from the reader and accepts a query", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/read");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Upload a PDF first so the reader has content
|
||||
// Upload a PDF first so the reader has content. `files-button` now
|
||||
// triggers the native picker directly - no modal flow involved.
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "hidden",
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The WorkbenchBar exposes a "Search PDF" button (aria-label="Search PDF")
|
||||
// that opens a Popover with the in-document search input.
|
||||
|
||||
@@ -121,6 +121,43 @@ test.describe("Settings dialog", () => {
|
||||
await restored.click();
|
||||
});
|
||||
|
||||
test("close returns to origin URL even after switching tabs (no history pile-up)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Land on / first so the originating URL is unambiguous.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const originPath = new URL(page.url()).pathname;
|
||||
const dialog = await openSettings(page);
|
||||
|
||||
// Click the same visible nav 3 times. In the buggy code each click
|
||||
// pushed a fresh history entry, so close-by-back popped only the
|
||||
// most recent tab change. The fix uses PUSH for the first nav (when
|
||||
// not yet in /settings/*) and REPLACE for subsequent navs, so the
|
||||
// origin URL stays at history depth 1 regardless of how many tabs
|
||||
// the user clicks through.
|
||||
const generalNav = dialog
|
||||
.locator('[data-tour="admin-general-nav"]')
|
||||
.first();
|
||||
await expect(generalNav).toBeVisible({ timeout: 5_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await generalNav.click();
|
||||
}
|
||||
// Wait for the URL to settle on /settings/general before closing so
|
||||
// we're not racing the in-modal nav under parallel-worker load.
|
||||
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
|
||||
|
||||
await closeSettings(page);
|
||||
// Wait for the URL pathname to settle back to origin. Match only
|
||||
// pathname so trailing ?query or #hash don't trip the assertion.
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 5_000 })
|
||||
.toBe(originPath);
|
||||
});
|
||||
|
||||
test("config sub-sections (System / Features / Endpoints / API Keys) are reachable when present", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -20,9 +20,9 @@ import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.1 Static layout — always visible on the main page
|
||||
// 15.1 Static layout - always visible on the main page
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.1 Tour selectors — static layout", () => {
|
||||
test.describe("15.1 Tour selectors - static layout", () => {
|
||||
test("tool-panel is present", async ({ page }) => {
|
||||
await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
@@ -55,7 +55,7 @@ test.describe("15.1 Tour selectors — static layout", () => {
|
||||
|
||||
// help-button: not yet implemented in the redesigned FileSidebar layout.
|
||||
// Re-enable once a tours/help entry point is added to the new UI.
|
||||
test.skip("help-button is present — TODO: add to new sidebar layout", async ({
|
||||
test.skip("help-button is present - TODO: add to new sidebar layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
|
||||
@@ -65,10 +65,18 @@ test.describe("15.1 Tour selectors — static layout", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.2 Tour selectors — files modal
|
||||
// 15.2 Tour selectors - files modal
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.2 Tour selectors — files modal", () => {
|
||||
test("file-sources is present when files modal is open", async ({ page }) => {
|
||||
// SKIPPED post-refactor: the FilesModal is no longer reachable from the
|
||||
// FileSidebar's `files-button` (which now triggers the native OS picker
|
||||
// directly). The modal is still rendered for AddFileCard /
|
||||
// PageEditorFileDropdown / LandingActions paths, but none of those have a
|
||||
// stable testid yet to drive from a spec. Re-enable once a `data-testid`
|
||||
// is added to one of the modal-opening entry points.
|
||||
test.describe("15.2 Tour selectors - files modal", () => {
|
||||
test.skip("file-sources is present when files modal is open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByTestId("files-button").click();
|
||||
await expect(
|
||||
page.locator('[data-tour="file-sources"]').first(),
|
||||
@@ -78,9 +86,9 @@ test.describe("15.2 Tour selectors — files modal", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.3 Tour selectors — workbench elements (require a loaded file)
|
||||
// 15.3 Tour selectors - workbench elements (require a loaded file)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.3 Tour selectors — workbench with file", () => {
|
||||
test.describe("15.3 Tour selectors - workbench with file", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
});
|
||||
@@ -99,10 +107,10 @@ test.describe("15.3 Tour selectors — workbench with file", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin)
|
||||
// 15.4 Tour selectors - active files view (file-card-checkbox, file-card-pin)
|
||||
// Two files → app auto-navigates to fileEditor (active files) mode.
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.4 Tour selectors — active files view", () => {
|
||||
test.describe("15.4 Tour selectors - active files view", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Two files → getStartupNavigationAction returns workbench:"fileEditor"
|
||||
await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]);
|
||||
@@ -132,9 +140,9 @@ test.describe("15.4 Tour selectors — active files view", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.5 Tour selectors — crop tool (crop-settings, run-button)
|
||||
// 15.5 Tour selectors - crop tool (crop-settings, run-button)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.5 Tour selectors — crop tool", () => {
|
||||
test.describe("15.5 Tour selectors - crop tool", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/crop", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
@@ -154,9 +162,9 @@ test.describe("15.5 Tour selectors — crop tool", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.6 Tour selectors — config modal (non-admin)
|
||||
// 15.6 Tour selectors - config modal (non-admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.6 Tour selectors — config modal", () => {
|
||||
test.describe("15.6 Tour selectors - config modal", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSettings(page);
|
||||
});
|
||||
@@ -175,13 +183,13 @@ test.describe("15.6 Tour selectors — config modal", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.7 Tour selectors — admin config modal nav items
|
||||
// 15.7 Tour selectors - admin config modal nav items
|
||||
// Requires isAdmin:true in app-config so the proprietary admin sections render.
|
||||
// These nav items are EE-only; the test is skipped in core-only builds where
|
||||
// the sections are not registered.
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.7 Tour selectors — admin modal nav items", () => {
|
||||
// enableLogin:true makes Landing require a session — seed a JWT so the
|
||||
test.describe("15.7 Tour selectors - admin modal nav items", () => {
|
||||
// enableLogin:true makes Landing require a session - seed a JWT so the
|
||||
// dashboard renders instead of redirecting to /login.
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: true },
|
||||
@@ -216,7 +224,7 @@ test.describe("15.7 Tour selectors — admin modal nav items", () => {
|
||||
if (!isPresent) {
|
||||
test.skip(
|
||||
true,
|
||||
`admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`,
|
||||
`admin-${section}-nav not rendered - section may be EE-only or not yet ported to this build`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ test.describe("Unsaved changes navigation guard", () => {
|
||||
await page.goto("/split");
|
||||
}
|
||||
|
||||
// After arriving at /split the file picker should still list the
|
||||
// After arriving at /split the My Files page should still list the
|
||||
// previously uploaded sample (NavigationGuard either kept us on
|
||||
// /merge or moved us with state intact). A "no files" empty state
|
||||
// here would indicate the guard silently dropped the workbench.
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.getByTestId("my-files-button").click();
|
||||
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { FolderId } from "@app/types/folder";
|
||||
|
||||
declare const tag: unique symbol;
|
||||
export type FileId = string & { readonly [tag]: "FileId" };
|
||||
@@ -36,6 +37,17 @@ export interface BaseFileMetadata {
|
||||
parentFileId?: FileId; // Immediate parent file ID
|
||||
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
||||
|
||||
/**
|
||||
* The cloud folder this file lives in. Semantics:
|
||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
||||
* - `remoteStorageId != null && folderId == null` → file is at the cloud root.
|
||||
* - `remoteStorageId != null && folderId == X` → file lives in cloud folder X.
|
||||
*
|
||||
* The "Local" pseudo-folder in the UI is the predicate `remoteStorageId == null`;
|
||||
* it has no corresponding {@code folderId} value. Folders are a server-only concept.
|
||||
*/
|
||||
folderId?: FolderId | null;
|
||||
|
||||
// Remote storage tracking
|
||||
remoteStorageId?: number; // Server-side storage ID for this file chain
|
||||
remoteStorageUpdatedAt?: number; // Timestamp when chain was last uploaded
|
||||
@@ -44,6 +56,7 @@ export interface BaseFileMetadata {
|
||||
remoteAccessRole?: string; // Access role for shared server files
|
||||
remoteSharedViaLink?: boolean; // True when imported from a share link
|
||||
remoteHasShareLinks?: boolean; // True when owner has shared this file
|
||||
remoteHasUserShares?: boolean; // True when owner has invited specific users
|
||||
remoteShareToken?: string; // Share token when file is from a share link
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Folder types for the advanced file manager.
|
||||
* Folders provide a hierarchical organization layer on top of the flat
|
||||
* file storage in IndexedDB. A folder is identified by a branded UUID
|
||||
* and may reference a parent folder; a `null` parent means the root.
|
||||
*/
|
||||
|
||||
declare const folderTag: unique symbol;
|
||||
export type FolderId = string & { readonly [folderTag]: "FolderId" };
|
||||
|
||||
/** The root folder is represented in UI/state by `null`. */
|
||||
export const ROOT_FOLDER_ID: null = null;
|
||||
|
||||
/**
|
||||
* UI sentinel for the pinned "Local" pseudo-folder. Never sent to the server,
|
||||
* never persisted to IndexedDB - only used as a `currentFolderId` value
|
||||
* to scope the file grid to local-only files. The underlying data predicate
|
||||
* is `remoteStorageId == null`.
|
||||
*/
|
||||
export const LOCAL_PSEUDO_FOLDER_ID = "__local__" as const;
|
||||
export type LocalPseudoFolderId = typeof LOCAL_PSEUDO_FOLDER_ID;
|
||||
|
||||
/** Default colour palette used when no explicit colour is provided. */
|
||||
export const FOLDER_COLOR_PALETTE = [
|
||||
"#3b82f6",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#14b8a6",
|
||||
"#f97316",
|
||||
] as const;
|
||||
|
||||
/** Members of {@link FOLDER_COLOR_PALETTE}. Use this rather than `string` to keep callers honest. */
|
||||
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
|
||||
|
||||
/** Persisted folder shape stored in IndexedDB. */
|
||||
export interface FolderRecord {
|
||||
id: FolderId;
|
||||
name: string;
|
||||
parentFolderId: FolderId | null;
|
||||
/** Hex colour - either a palette member or any custom hex from a future picker. */
|
||||
color?: string;
|
||||
icon?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder tree node - derived from FolderRecord[] for rendering the tree
|
||||
* navigator. Children are ordered by name (case-insensitive).
|
||||
*/
|
||||
export interface FolderTreeNode {
|
||||
folder: FolderRecord;
|
||||
children: FolderTreeNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** A path entry shown in the breadcrumb bar. `null` represents the root. */
|
||||
export interface FolderBreadcrumbEntry {
|
||||
id: FolderId | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic RFC-4122 UUID regex (case-insensitive). Accepts any valid UUID variant - server-side
|
||||
* {@code UUID.randomUUID()} is v4 in practice, but other tools and tests may produce v1/v3/v5.
|
||||
* Being strict-v4-only here turned the {@code pullFromServer} merge into a silent skip whenever
|
||||
* a non-v4 id arrived.
|
||||
*/
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Runtime check that a value is a UUID. Throws on garbage from the wire. */
|
||||
export function parseFolderId(value: unknown): FolderId {
|
||||
if (typeof value !== "string" || !UUID_REGEX.test(value)) {
|
||||
throw new Error(`Invalid FolderId: ${String(value)}`);
|
||||
}
|
||||
return value as FolderId;
|
||||
}
|
||||
|
||||
export function createFolderId(): FolderId {
|
||||
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID() as FolderId;
|
||||
}
|
||||
// Math.random fallback is non-cryptographic but acceptable here - these ids are
|
||||
// not used as security tokens, only as opaque local handles. The brand is the
|
||||
// contract; the entropy is best-effort.
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}) as FolderId;
|
||||
}
|
||||
|
||||
export function pickFolderColor(seed: string): FolderPaletteColor {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
const idx = Math.abs(hash) % FOLDER_COLOR_PALETTE.length;
|
||||
return FOLDER_COLOR_PALETTE[idx]!;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export const BASE_WORKBENCH_TYPES = [
|
||||
"viewer",
|
||||
"pageEditor",
|
||||
"fileEditor",
|
||||
"myFiles",
|
||||
] as const;
|
||||
|
||||
export type BaseWorkbenchType = (typeof BASE_WORKBENCH_TYPES)[number];
|
||||
|
||||
@@ -19,6 +19,12 @@ export function getStartupNavigationAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
// The user is browsing their file library - don't auto-switch them out of
|
||||
// the file manager just because a new upload landed.
|
||||
if (currentWorkbench === "myFiles") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already actively viewing in the viewer → update to the latest file
|
||||
if (
|
||||
previousFileCount > 0 &&
|
||||
|
||||
Reference in New Issue
Block a user