Add server-side folders and files page UI (#6383)

This commit is contained in:
Anthony Stirling
2026-05-27 12:52:46 +01:00
committed by GitHub
parent 4564ed5bec
commit d42b779644
90 changed files with 14720 additions and 663 deletions
@@ -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;
}
+321 -147
View File
@@ -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",
}}
/>
);
},
);
+147 -28
View File
@@ -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,
});
+13
View File
@@ -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
}
+104
View File
@@ -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 &&