Merge branch 'main' into SaaS

This commit is contained in:
Anthony Stirling
2026-06-10 11:42:25 +01:00
committed by GitHub
131 changed files with 17211 additions and 1451 deletions
@@ -33,6 +33,7 @@ import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
import { FolderProvider } from "@app/contexts/FolderContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
@@ -148,7 +149,9 @@ export function AppProviders({
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
@@ -62,6 +62,10 @@ export default function Workbench() {
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
const hasFiles = activeFiles.length > 0;
// Custom workbench views (e.g. Watched Folders) manage their own content and may
// have no workbench files, but still need the bar's view switcher so users can
// navigate back out.
const isCustomViewActive = !isBaseWorkbench(currentView);
// Enable bar transitions after first paint so the initial hidden state shows
// without animating (landing page on load shouldn't animate the bar up).
@@ -205,7 +209,7 @@ export default function Workbench() {
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-hidden={String(!hasFiles && !isCustomViewActive)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
@@ -1,6 +1,7 @@
import React, {
useState,
useCallback,
useMemo,
useRef,
useEffect,
forwardRef,
@@ -30,6 +31,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 FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
@@ -37,11 +39,23 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
import {
setWatchedFolderDraggedFileIds,
clearWatchedFolderDraggedFileIds,
} from "@app/components/watchedFolders/watchedFolderDragState";
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import "@app/components/shared/FileSidebar.css";
const COLLAPSED_WIDTH = "3.5rem";
const EXPANDED_WIDTH = "16.25rem"; // ~260px
// Inlined to avoid a circular import with WatchedFoldersRegistration.
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder";
export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
@@ -101,7 +115,60 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { state } = useFileState();
const { actions: fileActions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const { setCustomWorkbenchViewData, customWorkbenchViews } =
useToolWorkflow();
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
const isWatchedFoldersActive =
currentWorkbench === WATCHED_FOLDER_WORKBENCH_ID;
// The folder currently open in the Watched Folders view (null = folder list/home).
const activeWatchedFolderId = (customWorkbenchViews.find(
(v) => v.id === WATCHED_FOLDER_VIEW_ID,
)?.data?.folderId ?? null) as string | null;
// fileId → folderId[] across all watch folders. In the Watched Folders view the
// sidebar tick reflects "already in the open folder" instead of workbench
// membership (which is meaningless there - a click sends to the folder, not
// the workbench). The same map drives the per-file membership dots.
const folderMembership = useFolderMembership();
const allFolders = useAllWatchedFolders();
const folderById = useMemo(
() => new Map(allFolders.map((f) => [f.id, f])),
[allFolders],
);
const openWatchedFolders = useCallback(() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
// Clicking a file's membership dot jumps straight into that folder.
const openWatchedFolder = useCallback(
(folderId: string) => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
},
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
);
// In Watched Folders view, sidebar files can be dragged onto a folder card / drop
// zone (which read the watchedFolderFileId dataTransfer key).
const handleWatchedFolderDragStart = useCallback(
(e: React.DragEvent, fileId: FileId) => {
e.dataTransfer.setData("watchedFolderFileId", String(fileId));
e.dataTransfer.effectAllowed = "copy";
// Publish the id so drop targets can detect "already in folder" during
// dragover (dataTransfer values are unreadable then). Clear on dragend
// regardless of whether the drag ended in a drop or was cancelled.
setWatchedFolderDraggedFileIds([String(fileId)]);
const clear = () => {
clearWatchedFolderDraggedFileIds();
document.removeEventListener("dragend", clear);
};
document.addEventListener("dragend", clear);
},
[],
);
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
const { requestNavigation } = useNavigationGuard();
@@ -242,6 +309,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
// In the Watched Folders view a click sends the file into the open folder
// (mirrors how a click toggles a file into the active workbench elsewhere).
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
if (isWatchedFoldersActive) {
if (activeWatchedFolderId) {
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, {
folderId: activeWatchedFolderId,
pendingFileId: stub.id,
});
}
return;
}
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
@@ -291,6 +371,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
activeFileId,
requestNavigation,
isMultiTool,
isWatchedFoldersActive,
activeWatchedFolderId,
setCustomWorkbenchViewData,
],
);
@@ -676,6 +759,32 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</Tooltip>
)}
{/* Watched Folders entry */}
{WATCHED_FOLDERS_ENABLED && (
<div
className="file-sidebar-action-row"
data-testid="watchedFolders-button"
data-active={isWatchedFoldersActive}
onClick={openWatchedFolders}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openWatchedFolders()}
aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")}
style={
isWatchedFoldersActive
? { backgroundColor: "var(--active-bg)" }
: undefined
}
>
<FolderSpecialIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("watchedFolders.sidebarTitle", "Watched Folders")}
</span>
)}
</div>
)}
{/* Files section - always visible when expanded */}
{!collapsed && (
<div className="file-sidebar-files-section sidebar-content-fade">
@@ -716,6 +825,34 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
(id) => (id as string) === (stub.id as string),
);
const isInWorkbench = !!workbenchFileId;
// On Watched Folders, the tick means "this file is already in
// the open folder"; on the folder home (no folder open) a
// click is a no-op, so show no tick at all.
const isSelected = isWatchedFoldersActive
? activeWatchedFolderId != null &&
(folderMembership
.get(stub.id as string)
?.includes(activeWatchedFolderId) ??
false)
: isInWorkbench;
// Membership dots only on the Watched Folders home (the folder
// grid, no folder open). Inside a specific folder the tick
// already shows "in this folder"; in other views they'd just
// be noise.
const showFolderDots =
WATCHED_FOLDERS_ENABLED &&
isWatchedFoldersActive &&
activeWatchedFolderId === null;
const memberFolders = showFolderDots
? (folderMembership.get(stub.id as string) ?? [])
.map((fid) => folderById.get(fid))
.filter((f): f is NonNullable<typeof f> => !!f)
.map((f) => ({
id: f.id,
name: f.name,
accentColor: f.accentColor,
}))
: [];
// Both active and viewed-in-viewer are ID-based - never index-based.
const isViewedInViewer = !!(
viewedWorkbenchId &&
@@ -739,12 +876,16 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
name={stub.name}
size={stub.size}
lastModified={stub.lastModified}
isSelected={isInWorkbench}
isSelected={isSelected}
isActive={isActive}
isViewedInViewer={isViewedInViewer}
thumbnailUrl={thumbnailUrl}
onClick={handleFileClick}
onEyeClick={handleEyeClick}
draggable={isWatchedFoldersActive}
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
onFolderClick={openWatchedFolder}
/>
);
})}
@@ -118,11 +118,71 @@
color: #3b82f6;
}
.file-sidebar-file-meta-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
min-width: 0;
}
.file-sidebar-file-meta {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Folder membership tags ---- */
.file-sidebar-folder-tags {
display: flex;
align-items: center;
gap: 4px;
margin-top: 3px;
min-width: 0;
}
.file-sidebar-folder-tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 7.5rem;
padding: 1px 6px 1px 5px;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
transition: filter 0.1s ease;
}
.file-sidebar-folder-tag:hover {
filter: brightness(1.1);
}
.file-sidebar-folder-tag-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.file-sidebar-folder-tag-label {
font-size: 10px;
font-weight: 600;
line-height: 1.2;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-sidebar-folder-tag-more {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: var(--text-muted);
cursor: default;
flex-shrink: 0;
}
/* ---- Eye button (open in viewer) ---- */
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
@@ -117,6 +118,13 @@ function getSidebarFileIcon(ext: string): React.ReactElement {
return <FileDocIcon className={cls} variant={getFileDocVariant(ext)} />;
}
/** A Watched Folder this file currently belongs to, used for the membership dots. */
export interface FileItemFolderRef {
id: string;
name: string;
accentColor: string;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -128,8 +136,17 @@ export interface FileItemProps {
thumbnailUrl?: string;
onClick: (fileId: FileId) => void;
onEyeClick: (fileId: FileId, e: React.MouseEvent) => void;
/** When true, the row can be dragged (e.g. onto a Watched Folder). */
draggable?: boolean;
onDragStart?: (e: React.DragEvent, fileId: FileId) => void;
/** Watched Folders this file is in — rendered as small accent dots. */
folders?: FileItemFolderRef[];
/** Clicking a membership dot opens that folder. */
onFolderClick?: (folderId: string) => void;
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
export function FileItem({
fileId,
name,
@@ -141,12 +158,19 @@ export function FileItem({
thumbnailUrl,
onClick,
onEyeClick,
draggable,
onDragStart,
folders = [],
onFolderClick,
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
// Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons
const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext);
const resolvedThumbnail = useLazyThumbnail(
@@ -179,6 +203,10 @@ export function FileItem({
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
onClick={() => onClick(fileId)}
draggable={draggable}
onDragStart={
draggable && onDragStart ? (e) => onDragStart(e, fileId) : undefined
}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick(fileId)}
@@ -203,11 +231,61 @@ export function FileItem({
>
{name}
</span>
<span className="file-sidebar-file-meta">
{dateLabel}
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
<span className="file-sidebar-file-meta-row">
<span className="file-sidebar-file-meta">
{dateLabel}
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
{visibleFolders.map((folder) => (
<Tooltip
key={folder.id}
label={folder.name}
withArrow
position="top"
withinPortal
>
<span
className="file-sidebar-folder-tag"
style={{
backgroundColor: `${folder.accentColor}1f`,
borderColor: `${folder.accentColor}55`,
}}
role="button"
tabIndex={-1}
aria-label={folder.name}
onClick={(e) => {
e.stopPropagation();
onFolderClick?.(folder.id);
}}
>
<span
className="file-sidebar-folder-tag-dot"
style={{ backgroundColor: folder.accentColor }}
/>
<span className="file-sidebar-folder-tag-label">
{folder.name}
</span>
</span>
</Tooltip>
))}
{overflowFolders.length > 0 && (
<Tooltip
label={overflowFolders.map((f) => f.name).join(", ")}
withArrow
position="top"
withinPortal
>
<span className="file-sidebar-folder-tag-more">
+{overflowFolders.length}
</span>
</Tooltip>
)}
</span>
)}
</div>
<button
className="file-sidebar-eye-btn"
@@ -411,7 +411,7 @@ export default function WorkbenchBar({
<div className="workbench-bar-divider" />
</>
)}
{hasFiles &&
{(hasFiles || isCustomView) &&
viewOptions.map((opt) => (
<button
key={opt.value}
@@ -468,28 +468,30 @@ export default function WorkbenchBar({
t("workbenchBar.print", "Print PDF"),
)}
{/* Download */}
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon
icon={icons.downloadIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
)}
{/* Download (file-level action — not relevant in custom views) */}
{!isCustomView &&
renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="workbench-bar-action-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
>
<LocalIcon
icon={icons.downloadIconName}
width="1rem"
height="1rem"
/>
</ActionIcon>,
downloadTooltip,
)}
{/* Save As */}
{icons.saveAsIconName &&
{!isCustomView &&
icons.saveAsIconName &&
renderWithTooltip(
<ActionIcon
variant="subtle"
@@ -40,6 +40,7 @@ const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"help",
] as const;
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
@@ -34,6 +34,15 @@ interface AutomationCreationProps {
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: Partial<ToolRegistry>;
/** Hide the name/description/icon fields and the action buttons. Used when embedding
* the form (e.g. inside the Watched Folder modal) where the host owns those controls. */
hideMetadata?: boolean;
/** Force the automation name (used with hideMetadata so the host's name drives it). */
nameOverride?: string;
/** Called when an external save trigger fires but the form isn't in a saveable state. */
onSaveFailed?: () => void;
/** When provided, the host can trigger save imperatively (the internal Save button is hidden). */
saveTriggerRef?: React.MutableRefObject<(() => void) | null>;
}
export default function AutomationCreation({
@@ -42,6 +51,10 @@ export default function AutomationCreation({
onBack,
onComplete,
toolRegistry,
hideMetadata = false,
nameOverride,
onSaveFailed,
saveTriggerRef,
}: AutomationCreationProps) {
const { t } = useTranslation();
@@ -154,6 +167,29 @@ export default function AutomationCreation({
}
};
// Keep the (hidden) automation name in sync with a host-provided override.
useEffect(() => {
if (nameOverride !== undefined) {
setAutomationName(nameOverride);
}
}, [nameOverride, setAutomationName]);
// Expose an imperative save trigger to the host (e.g. the Watched Folder modal's
// "Create Folder" button). Surfaces a failure callback when not saveable.
useEffect(() => {
if (!saveTriggerRef) return;
saveTriggerRef.current = () => {
if (!canSaveAutomation()) {
onSaveFailed?.();
return;
}
void saveAutomation();
};
return () => {
saveTriggerRef.current = null;
};
});
const currentConfigTool =
configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
@@ -195,41 +231,45 @@ export default function AutomationCreation({
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name and Icon */}
<Group gap="xs" align="flex-end">
<Stack gap="xs" style={{ flex: 1 }}>
<TextInput
{!hideMetadata && (
<>
{/* Automation Name and Icon */}
<Group gap="xs" align="flex-end">
<Stack gap="xs" style={{ flex: 1 }}>
<TextInput
placeholder={t(
"automate.creation.name.placeholder",
"My Automation",
)}
value={automationName}
withAsterisk
label={t("automate.creation.name.label", "Automation Name")}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
</Stack>
<IconSelector
value={automationIcon || "SettingsIcon"}
onChange={setAutomationIcon}
size="sm"
/>
</Group>
{/* Automation Description */}
<Textarea
placeholder={t(
"automate.creation.name.placeholder",
"My Automation",
"automate.creation.description.placeholder",
"Describe what this automation does...",
)}
value={automationName}
withAsterisk
label={t("automate.creation.name.label", "Automation Name")}
onChange={(e) => setAutomationName(e.currentTarget.value)}
value={automationDescription}
label={t("automate.creation.description.label", "Description")}
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
size="sm"
rows={3}
/>
</Stack>
<IconSelector
value={automationIcon || "SettingsIcon"}
onChange={setAutomationIcon}
size="sm"
/>
</Group>
{/* Automation Description */}
<Textarea
placeholder={t(
"automate.creation.description.placeholder",
"Describe what this automation does...",
)}
value={automationDescription}
label={t("automate.creation.description.label", "Description")}
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
size="sm"
rows={3}
/>
</>
)}
{/* Selected Tools List */}
{selectedTools.length > 0 && (
@@ -245,48 +285,52 @@ export default function AutomationCreation({
/>
)}
<Divider />
{!saveTriggerRef && (
<>
<Divider />
{/* Action Buttons */}
<Stack gap="sm">
<Button
leftSection={<CheckIcon />}
onClick={saveAutomation}
disabled={!canSaveAutomation()}
fullWidth
>
{t("automate.creation.save", "Save Automation")}
</Button>
{/* Action Buttons */}
<Stack gap="sm">
<Button
leftSection={<CheckIcon />}
onClick={saveAutomation}
disabled={!canSaveAutomation()}
fullWidth
>
{t("automate.creation.save", "Save Automation")}
</Button>
<Group gap="sm" grow>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadAutomationConfig(buildExportableAutomation());
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t("automate.creation.export", "Export")}
</Button>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadFolderScanningConfig(
buildExportableAutomation(),
toolRegistry,
);
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t(
"automate.creation.exportForFolderScanning",
"Export for Folder Scanning",
)}
</Button>
</Group>
</Stack>
<Group gap="sm" grow>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadAutomationConfig(buildExportableAutomation());
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t("automate.creation.export", "Export")}
</Button>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadFolderScanningConfig(
buildExportableAutomation(),
toolRegistry,
);
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t(
"automate.creation.exportForFolderScanning",
"Export for Folder Scanning",
)}
</Button>
</Group>
</Stack>
</>
)}
</Stack>
{/* Tool Configuration Modal */}
@@ -0,0 +1,23 @@
/**
* Tracks which file ids are currently being dragged toward a Watched Folder.
*
* The HTML5 DnD spec hides `dataTransfer` values during `dragover`/`dragenter`
* (they're only readable on `drop`), so a drop target can't tell *which* file is
* hovering over it mid-drag. Drag sources publish the ids here on `dragstart`;
* drop targets read them during `dragover` to give live feedback (e.g. "already
* in this folder"). Cleared on `dragend`.
*/
let draggedFileIds: string[] = [];
export function setWatchedFolderDraggedFileIds(ids: string[]): void {
draggedFileIds = ids;
}
export function getWatchedFolderDraggedFileIds(): string[] {
return draggedFileIds;
}
export function clearWatchedFolderDraggedFileIds(): void {
draggedFileIds = [];
}
@@ -0,0 +1,14 @@
/**
* Build-time feature gates. Flip a flag back to `true` to re-enable a feature
* that's been temporarily pulled from the UI.
*/
/**
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
* custom workbench registration (seeding, URL sync, view) and the sidebar
* entry point, so the feature is unreachable from the UI. Flip to `true` to
* restore access.
*/
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
/**
* Core stub for the Watched Folders file context.
*
* Watched Folders is a proprietary feature — its real provider lives in
* `proprietary/contexts/FolderFileContext.tsx`. The core build has no Watch
* Folders consumers, so this is a pass-through that keeps `AppProviders`
* (shared) compiling in the open-source build without pulling the feature in.
*/
export function FolderFileContextProvider({
children,
}: {
children: ReactNode;
}) {
return <>{children}</>;
}
@@ -1083,7 +1083,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
() =>
import("@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"),
),
synonyms: getSynonyms(t, "ScannerImageSplit"),
synonyms: getSynonyms(t, "scannerImageSplit"),
},
overlayPdfs: {
icon: (
@@ -0,0 +1,33 @@
/**
* Read-only hook that returns all smart folders, kept in sync with storage changes.
* Use this when you only need to read the folder list without CRUD operations.
* For full CRUD, use useWatchedFolders instead.
*/
import { useState, useEffect } from "react";
import { WatchedFolder } from "@app/types/watchedFolders";
import {
watchedFolderStorage,
WATCHED_FOLDER_STORAGE_CHANGE_EVENT,
} from "@app/services/watchedFolderStorage";
export function useAllWatchedFolders(): WatchedFolder[] {
const [folders, setFolders] = useState<WatchedFolder[]>([]);
useEffect(() => {
const load = async () => {
try {
setFolders(await watchedFolderStorage.getAllFolders());
} catch (err) {
console.error("Failed to load smart folders:", err);
}
};
load();
window.addEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, load);
return () =>
window.removeEventListener(WATCHED_FOLDER_STORAGE_CHANGE_EVENT, load);
}, []);
return folders;
}
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback } from "react";
export type CardModalPhase =
| "closed"
| "entering"
| "header-open"
| "open"
| "closing-body"
| "closing-header";
const TIMINGS = {
headerStretch: 220,
bodyFireDelay: 130,
bodyDrop: 90,
textAccordion: 25,
closeBody: 150,
closeHeader: 150,
closeStretch: 140,
} as const;
export { TIMINGS as CARD_MODAL_TIMINGS };
interface UseCardModalAnimationReturn {
phase: CardModalPhase;
cardRect: DOMRect | null;
textExpanded: boolean;
openModal: (rect: DOMRect) => void;
closeModal: () => void;
}
export function useCardModalAnimation(): UseCardModalAnimationReturn {
const [phase, setPhase] = useState<CardModalPhase>("closed");
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
const [textExpanded, setTextExpanded] = useState(false);
useEffect(() => {
if (phase === "entering") {
const raf = requestAnimationFrame(() => setPhase("header-open"));
return () => cancelAnimationFrame(raf);
}
if (phase === "header-open") {
const t1 = setTimeout(() => setTextExpanded(true), TIMINGS.textAccordion);
const t2 = setTimeout(() => setPhase("open"), TIMINGS.bodyFireDelay);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}
if (phase === "closing-body") {
const t = setTimeout(() => setPhase("closing-header"), TIMINGS.closeBody);
return () => clearTimeout(t);
}
if (phase === "closing-header") {
const t = setTimeout(() => {
setPhase("closed");
setTextExpanded(false);
}, TIMINGS.closeHeader);
return () => clearTimeout(t);
}
}, [phase]);
const openModal = useCallback((rect: DOMRect) => {
setCardRect(rect);
setPhase("entering");
}, []);
const closeModal = useCallback(() => {
setPhase("closing-body");
}, []);
return { phase, cardRect, textExpanded, openModal, closeModal };
}
@@ -0,0 +1,57 @@
/**
* Returns a map of fileId → folderId[] for all files currently in any watched folder.
* Both input files (keyed by their FileId in stirling-pdf-files) and their output
* counterparts (displayFileId) are included so folder tags show on both versions.
*/
import { useState, useEffect } from "react";
import { watchedFolderFileStorage } from "@app/services/watchedFolderFileStorage";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
export function useFolderMembership(): Map<string, string[]> {
const folders = useAllWatchedFolders();
const [membership, setMembership] = useState<Map<string, string[]>>(
new Map(),
);
useEffect(() => {
if (folders.length === 0) {
setMembership(new Map());
return;
}
const load = async () => {
const map = new Map<string, string[]>();
const add = (fileId: string, folderId: string) => {
const existing = map.get(fileId);
if (existing) {
if (!existing.includes(folderId)) existing.push(folderId);
} else map.set(fileId, [folderId]);
};
for (const folder of folders) {
try {
const record = await watchedFolderFileStorage.getFolderData(
folder.id,
);
if (record) {
Object.entries(record.files).forEach(([fileId, meta]) => {
add(fileId, folder.id);
const outputIds =
meta?.displayFileIds ??
(meta?.displayFileId ? [meta.displayFileId] : []);
outputIds.forEach((oid) => add(oid, folder.id));
});
}
} catch {
// ignore individual folder failures
}
}
setMembership(map);
};
load();
return watchedFolderFileStorage.onFolderChange(load);
}, [folders]);
return membership;
}
@@ -0,0 +1,214 @@
/**
* Service for managing folder-file associations in IndexedDB.
* File blobs are stored in the main stirling-pdf-files database (fileStorage).
* This service only maintains folder record metadata: which file IDs belong to
* which folders and their processing status.
*/
import { FolderFileMetadata, FolderRecord } from "@app/types/watchedFolders";
const FOLDER_CHANGE_EVENT = "folder-storage-changed";
class WatchedFolderFileStorage {
private dbName = "stirling-pdf-folder-files";
private dbVersion = 3;
private recordsStore = "folderRecords";
private db: IDBDatabase | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error("Failed to open folder files database"));
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.recordsStore)) {
db.createObjectStore(this.recordsStore, { keyPath: "folderId" });
}
if (db.objectStoreNames.contains("folderOutputFiles")) {
db.deleteObjectStore("folderOutputFiles");
}
if (db.objectStoreNames.contains("folderInputFiles")) {
db.deleteObjectStore("folderInputFiles");
}
};
});
}
private async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
await this.init();
}
if (!this.db) {
throw new Error("Folder files database not initialized");
}
return this.db;
}
private dispatchChange(folderId: string): void {
window.dispatchEvent(
new CustomEvent(FOLDER_CHANGE_EVENT, { detail: { folderId } }),
);
}
onFolderChange(listener: (folderId: string) => void): () => void {
const handler = (e: Event) => {
listener((e as CustomEvent).detail.folderId);
};
window.addEventListener(FOLDER_CHANGE_EVENT, handler);
return () => window.removeEventListener(FOLDER_CHANGE_EVENT, handler);
}
async getFolderData(folderId: string): Promise<FolderRecord | null> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readonly");
const store = transaction.objectStore(this.recordsStore);
const request = store.get(folderId);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(new Error("Failed to get folder data"));
});
}
async addFileToFolder(
folderId: string,
fileId: string,
metadata?: Partial<FolderFileMetadata>,
): Promise<void> {
const db = await this.ensureDB();
const now = new Date();
return new Promise((resolve, reject) => {
// Single readwrite transaction for both read and write — prevents lost-update
// races when multiple files are added to the same folder concurrently.
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const record: FolderRecord = getRequest.result || {
folderId,
files: {},
lastUpdated: Date.now(),
};
record.files[fileId] = { addedAt: now, status: "pending", ...metadata };
record.lastUpdated = Date.now();
const putRequest = store.put(record);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () =>
reject(new Error("Failed to add file to folder"));
};
getRequest.onerror = () =>
reject(new Error("Failed to read folder for add"));
});
}
async updateFileMetadata(
folderId: string,
fileId: string,
updates: Partial<FolderFileMetadata>,
): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
// Single readwrite transaction — prevents lost-update races during concurrent
// pipeline runs where multiple files update their status simultaneously.
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const existing: FolderRecord | undefined = getRequest.result;
if (!existing) {
resolve();
return;
}
existing.files[fileId] = { ...existing.files[fileId], ...updates };
existing.lastUpdated = Date.now();
const putRequest = store.put(existing);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () =>
reject(new Error("Failed to update file metadata"));
};
getRequest.onerror = () =>
reject(new Error("Failed to read folder for update"));
});
}
async removeFileFromFolder(folderId: string, fileId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const existing: FolderRecord | undefined = getRequest.result;
if (!existing) {
resolve();
return;
}
delete existing.files[fileId];
existing.lastUpdated = Date.now();
const putRequest = store.put(existing);
putRequest.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
putRequest.onerror = () =>
reject(new Error("Failed to remove file from folder"));
};
getRequest.onerror = () =>
reject(new Error("Failed to read folder for remove"));
});
}
/**
* Overwrite the entire folder record.
* Pass `{ silent: true }` for mirror writes — these reflect a read,
* not a user action, so dispatching change events would cause subscribers
* (which themselves call getFolderData) to re-fetch in an infinite loop.
*/
async setFolderData(
folderId: string,
record: FolderRecord,
opts?: { silent?: boolean },
): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const request = store.put(record);
request.onsuccess = () => {
if (!opts?.silent) this.dispatchChange(folderId);
resolve();
};
request.onerror = () => reject(new Error("Failed to set folder data"));
});
}
async clearFolder(folderId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.recordsStore], "readwrite");
const store = transaction.objectStore(this.recordsStore);
const request = store.delete(folderId);
request.onsuccess = () => {
this.dispatchChange(folderId);
resolve();
};
request.onerror = () => reject(new Error("Failed to clear folder"));
});
}
}
export const watchedFolderFileStorage = new WatchedFolderFileStorage();
@@ -0,0 +1,168 @@
/**
* Service for managing Watched Folder configurations in IndexedDB
*/
import { WatchedFolder } from "@app/types/watchedFolders";
const STORAGE_CHANGE_EVENT = "watched-folder-storage-changed";
class WatchedFolderStorage {
private dbName = "stirling-pdf-watched-folders";
private dbVersion = 1;
private storeName = "watchedFolders";
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error("Failed to open smart folder storage database"));
};
request.onsuccess = () => {
this.db = request.result;
this.db.onclose = () => {
this.db = null;
this.initPromise = null;
};
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
store.createIndex("name", "name", { unique: false });
store.createIndex("createdAt", "createdAt", { unique: false });
store.createIndex("order", "order", { unique: false });
}
};
});
}
private async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
this.initPromise ??= this.init();
await this.initPromise;
}
if (!this.db) {
throw new Error("Smart folder database not initialized");
}
return this.db;
}
private dispatchChange(): void {
window.dispatchEvent(new Event(STORAGE_CHANGE_EVENT));
}
async getAllFolders(): Promise<WatchedFolder[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const folders: WatchedFolder[] = request.result || [];
folders.sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return (
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
});
resolve(folders);
};
request.onerror = () => reject(new Error("Failed to get smart folders"));
});
}
async getFolder(id: string): Promise<WatchedFolder | null> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(new Error("Failed to get smart folder"));
});
}
async createFolder(
data: Omit<WatchedFolder, "id" | "createdAt" | "updatedAt">,
): Promise<WatchedFolder> {
const db = await this.ensureDB();
const timestamp = new Date().toISOString();
const folder: WatchedFolder = {
id: crypto.randomUUID(),
...data,
createdAt: timestamp,
updatedAt: timestamp,
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.add(folder);
request.onsuccess = () => {
this.dispatchChange();
resolve(folder);
};
request.onerror = () =>
reject(new Error("Failed to create smart folder"));
});
}
async createFolderWithId(folder: WatchedFolder): Promise<WatchedFolder> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put(folder);
request.onsuccess = () => {
this.dispatchChange();
resolve(folder);
};
request.onerror = () =>
reject(new Error("Failed to create smart folder with id"));
});
}
async updateFolder(folder: WatchedFolder): Promise<WatchedFolder> {
const db = await this.ensureDB();
const updated: WatchedFolder = {
...folder,
updatedAt: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put(updated);
request.onsuccess = () => {
this.dispatchChange();
resolve(updated);
};
request.onerror = () =>
reject(new Error("Failed to update smart folder"));
});
}
async deleteFolder(id: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
this.dispatchChange();
resolve();
};
request.onerror = () =>
reject(new Error("Failed to delete smart folder"));
});
}
}
export const watchedFolderStorage = new WatchedFolderStorage();
export { STORAGE_CHANGE_EVENT as WATCHED_FOLDER_STORAGE_CHANGE_EVENT };
@@ -202,6 +202,8 @@
--icon-sign-color: #ffffff;
--icon-automate-bg: #a576e3;
--icon-automate-color: #ffffff;
--icon-watchedFolders-bg: #f59e0b;
--icon-watchedFolders-color: #ffffff;
--icon-files-bg: #d3e7f7;
--icon-files-color: #0a8bff;
--icon-activity-bg: #d3e7f7;
@@ -535,6 +537,8 @@
--icon-sign-color: #eaeaea;
--icon-automate-bg: #4b525a;
--icon-automate-color: #eaeaea;
--icon-watchedFolders-bg: #4b525a;
--icon-watchedFolders-color: #eaeaea;
--icon-files-bg: #4b525a;
--icon-files-color: #eaeaea;
--icon-activity-bg: #4b525a;
@@ -0,0 +1,577 @@
/**
* Comprehensive Playwright tests for the Watched Folders feature.
*
* Tests cover: navigation, folder CRUD, preset seeding, drag-and-drop,
* modal interactions, sidebar integration, IndexedDB state, and error states.
*
* Run: npx playwright test watched-folders --project=chromium --reporter=list
*/
import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
// Watched Folders ships behind the WATCHED_FOLDERS_ENABLED flag, which is off in
// every build today, so its entry points never render. This live suite is
// therefore skipped unless the app under test is built with the feature enabled
// and the runner opts in via WATCHED_FOLDERS_E2E=1. When re-enabling, also revisit
// the sidebar entry point (the old QuickAccessBar button no longer exists).
test.beforeEach(() => {
const enabled = ["1", "true"].includes(process.env.WATCHED_FOLDERS_E2E ?? "");
test.skip(
!enabled,
"Watched Folders is feature-flagged off; set WATCHED_FOLDERS_E2E=1 to run",
);
});
async function setupApp(page: Page): Promise<void> {
// Use the shared login helper (real UI login with the bootstrapped
// `admin / adminadmin` credentials). The previous bespoke /api/v1/auth/login
// call used the pre-bootstrap `stirling` password and always 401'd.
await loginAndSetup(page);
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
timeout: 30000,
});
}
async function navigateToWatchedFolders(page: Page): Promise<void> {
await page.locator('[data-testid="watchedFolders-button"]').click();
await page.waitForSelector('button:has-text("New folder")', {
timeout: 10000,
});
}
async function getIDBFolderCount(page: Page): Promise<number> {
return page.evaluate(async () => {
return new Promise<number>((resolve) => {
const req = indexedDB.open("stirling-pdf-watched-folders");
req.onsuccess = () => {
const db = req.result;
const storeName = db.objectStoreNames[0];
if (!storeName) {
resolve(0);
return;
}
const tx = db.transaction(storeName, "readonly");
const count = tx.objectStore(storeName).count();
count.onsuccess = () => resolve(count.result);
count.onerror = () => resolve(0);
};
req.onerror = () => resolve(0);
});
});
}
async function getIDBFolders(
page: Page,
): Promise<{ id: string; name: string }[]> {
return page.evaluate(async () => {
return new Promise<{ id: string; name: string }[]>((resolve) => {
const req = indexedDB.open("stirling-pdf-watched-folders");
req.onsuccess = () => {
const db = req.result;
const storeName = db.objectStoreNames[0];
if (!storeName) {
resolve([]);
return;
}
const tx = db.transaction(storeName, "readonly");
const all = tx.objectStore(storeName).getAll();
all.onsuccess = () =>
resolve(
(all.result || []).map((f: any) => ({ id: f.id, name: f.name })),
);
all.onerror = () => resolve([]);
};
req.onerror = () => resolve([]);
});
});
}
async function clearAllIDBFolders(page: Page): Promise<void> {
await page.evaluate(async () => {
const dbNames = [
"stirling-pdf-watched-folders",
"stirling-pdf-folder-files",
"stirling-pdf-folder-run-state",
"stirling-pdf-retry-schedule",
"stirling-pdf-folder-seen-files",
"stirling-pdf-folder-directory-handles",
];
for (const name of dbNames) {
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
}
localStorage.removeItem("watched_folders_seeded");
});
}
// ---------------------------------------------------------------------------
// Test: Navigation
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Navigation", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
});
test("sidebar button navigates to Watched Folders home", async ({ page }) => {
await page.locator('[data-testid="watchedFolders-button"]').click();
// Should see the home page with "New folder" button
await expect(
page.getByRole("button", { name: "New folder" }).first(),
).toBeVisible({ timeout: 10000 });
});
test("clicking Watched Folders button twice returns to home", async ({
page,
}) => {
await navigateToWatchedFolders(page);
// Click a folder card to navigate into it, then click the button again
const firstCard = page.locator('[data-testid="watchedFolders-button"]');
await firstCard.click();
await expect(
page.getByRole("button", { name: "New folder" }).first(),
).toBeVisible({ timeout: 10000 });
});
});
// ---------------------------------------------------------------------------
// Test: Preset Seeding
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Presets", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await clearAllIDBFolders(page);
});
test("seeds 4 default folders on first visit", async ({ page }) => {
// Navigate to watched folders — this triggers WatchedFoldersRegistration which calls seedDefaultFolders
await navigateToWatchedFolders(page);
// Wait a bit for seeding to complete
await page.waitForTimeout(2000);
const count = await getIDBFolderCount(page);
expect(count).toBe(4);
const folders = await getIDBFolders(page);
const names = folders.map((f) => f.name).sort();
expect(names).toEqual([
"Email Prep",
"Pre-publish",
"Rotate & Optimise",
"Secure Ingestion",
]);
});
test("does not re-seed on second visit", async ({ page }) => {
await navigateToWatchedFolders(page);
await page.waitForTimeout(2000);
const count1 = await getIDBFolderCount(page);
// Navigate away and back
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
timeout: 15000,
});
await navigateToWatchedFolders(page);
await page.waitForTimeout(1000);
const count2 = await getIDBFolderCount(page);
expect(count2).toBe(count1);
});
});
// ---------------------------------------------------------------------------
// Test: Folder CRUD
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Create / Edit / Delete", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
});
test("create a new folder via modal", async ({ page }) => {
await navigateToWatchedFolders(page);
const initialCount = await getIDBFolderCount(page);
// Open modal
await page.getByRole("button", { name: "New folder" }).first().click();
await page.waitForTimeout(500);
// Fill name
const nameInput = page.getByPlaceholder("My watched folder");
if (await nameInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await nameInput.fill("Test Folder");
} else {
// Fallback — find the first text input in the modal
await page.locator('input[type="text"]').first().fill("Test Folder");
}
// We need to configure at least the minimum tools before save will work
// Try to find and fill tool slots
const toolInputs = page.getByPlaceholder("Select a tool...");
if (
await toolInputs
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await toolInputs.first().click();
await page.waitForTimeout(200);
await toolInputs.first().fill("compress");
await page.waitForTimeout(400);
// Click the compress option
const compressOption = page
.getByRole("button", { name: /compress/i })
.first();
if (
await compressOption.isVisible({ timeout: 2000 }).catch(() => false)
) {
await compressOption.click();
await page.waitForTimeout(300);
}
}
// Try to save
const createBtn = page.getByText("Create Folder");
if (await createBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await createBtn.click();
await page.waitForTimeout(2000);
}
const newCount = await getIDBFolderCount(page);
// Should have at least one more folder than before
expect(newCount).toBeGreaterThanOrEqual(initialCount);
});
test("delete a folder cleans up all related IDB stores", async ({ page }) => {
await navigateToWatchedFolders(page);
await page.waitForTimeout(1000);
const folders = await getIDBFolders(page);
if (folders.length === 0) {
test.skip();
return;
}
const targetFolder = folders[0];
// Delete via IDB directly and check cleanup
await page.evaluate(
async ({ folderId }) => {
// Seed some test data in related stores
const seedStore = (
dbName: string,
storeName: string,
key: string,
value: any,
) =>
new Promise<void>((resolve) => {
const req = indexedDB.open(dbName);
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains(storeName)) {
resolve();
return;
}
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
};
req.onerror = () => resolve();
});
await seedStore(
"stirling-pdf-folder-seen-files",
"seenFiles",
`${folderId}|test.pdf|1234|5678`,
Date.now(),
);
},
{ folderId: targetFolder.id },
);
// Now trigger folder deletion via the hook mechanism
// We simulate what the delete button does by calling the storage directly
await page.evaluate(
async ({ folderId }) => {
// Delete from watched folder storage
await new Promise<void>((resolve) => {
const req = indexedDB.open("stirling-pdf-watched-folders");
req.onsuccess = () => {
const db = req.result;
const storeName = db.objectStoreNames[0];
if (!storeName) {
resolve();
return;
}
const tx = db.transaction(storeName, "readwrite");
tx.objectStore(storeName).delete(folderId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
};
req.onerror = () => resolve();
});
},
{ folderId: targetFolder.id },
);
// Verify the folder is gone
const remaining = await getIDBFolders(page);
expect(remaining.find((f) => f.id === targetFolder.id)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Test: Management Modal
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Management Modal", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await navigateToWatchedFolders(page);
});
test("modal opens and shows name input", async ({ page }) => {
await page.getByRole("button", { name: "New folder" }).first().click();
await page.waitForTimeout(500);
// Should show the name input
const nameInput = page.getByPlaceholder("My watched folder");
await expect(nameInput).toBeVisible({ timeout: 5000 });
});
test("modal closes on Escape key", async ({ page }) => {
await page.getByRole("button", { name: "New folder" }).first().click();
await page.waitForTimeout(500);
const nameInput = page.getByPlaceholder("My watched folder");
await expect(nameInput).toBeVisible({ timeout: 5000 });
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// Modal should be gone
await expect(nameInput).toBeHidden({ timeout: 5000 });
});
test("name input enforces 50 character limit", async ({ page }) => {
await page.getByRole("button", { name: "New folder" }).first().click();
await page.waitForTimeout(500);
const nameInput = page.getByPlaceholder("My watched folder");
await expect(nameInput).toBeVisible({ timeout: 5000 });
// Type a very long string
const longName = "A".repeat(60);
await nameInput.fill(longName);
const value = await nameInput.inputValue();
expect(value.length).toBeLessThanOrEqual(50);
});
});
// ---------------------------------------------------------------------------
// Test: Home Page UI
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Home Page", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await navigateToWatchedFolders(page);
await page.waitForTimeout(1500); // wait for seeding
});
test("displays folder cards for seeded presets", async ({ page }) => {
// Should see at least some folder cards
const folderNames = [
"Secure Ingestion",
"Pre-publish",
"Email Prep",
"Rotate & Optimise",
];
for (const name of folderNames) {
const card = page.getByText(name).first();
const visible = await card
.isVisible({ timeout: 3000 })
.catch(() => false);
if (visible) {
expect(visible).toBe(true);
}
}
});
test('shows "How it works" section on first visit', async ({ page }) => {
// Clear the session storage flag
await page.evaluate(() =>
sessionStorage.removeItem("watchedFolderHowItWorksDismissed"),
);
// Re-navigate
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForSelector('[data-testid="watchedFolders-button"]', {
timeout: 15000,
});
await navigateToWatchedFolders(page);
await page.waitForTimeout(1000);
// Look for "How" text
const howItWorks = page.getByText(/How.*[Ww]atch.*[Ff]olders.*work/i);
const visible = await howItWorks
.isVisible({ timeout: 3000 })
.catch(() => false);
// This is expected to be visible on first visit (if not dismissed)
// Don't hard-fail if not found — it may have been dismissed in session
if (visible) {
expect(visible).toBe(true);
}
});
test('"New folder" button is present and clickable', async ({ page }) => {
const btn = page.getByRole("button", { name: "New folder" }).first();
await expect(btn).toBeVisible();
await expect(btn).toBeEnabled();
});
});
// ---------------------------------------------------------------------------
// Test: Sidebar Section
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Sidebar", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await navigateToWatchedFolders(page);
await page.waitForTimeout(1500);
});
test("sidebar shows folder entries", async ({ page }) => {
// The sidebar should have a section with folder names
const sidebar = page
.locator('[class*="sidebar"], [data-testid*="sidebar"]')
.first();
if (await sidebar.isVisible({ timeout: 3000 }).catch(() => false)) {
// Check for at least one folder name in the sidebar area
const sidebarText = await sidebar.textContent();
// Should contain at least one preset name
const hasFolder = ["Secure", "Pre-publish", "Email", "Rotate"].some(
(name) => sidebarText?.includes(name),
);
expect(hasFolder).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Test: IndexedDB Storage Integrity
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Storage Integrity", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
});
test("folder IDs are valid UUIDs (no prefix)", async ({ page }) => {
await navigateToWatchedFolders(page);
await page.waitForTimeout(2000);
const folders = await getIDBFolders(page);
for (const folder of folders) {
// Should be a valid UUID format
expect(folder.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
// Should NOT have the old prefix
expect(folder.id).not.toMatch(/^folder-/);
}
});
test("seeded flag is set in localStorage after seeding", async ({ page }) => {
await navigateToWatchedFolders(page);
await page.waitForTimeout(2000);
const flag = await page.evaluate(() =>
localStorage.getItem("watched_folders_seeded"),
);
expect(flag).toBe("true");
});
test("clearing localStorage flag and reloading re-seeds folders", async ({
page,
}) => {
await clearAllIDBFolders(page);
// Navigate to trigger seeding
await navigateToWatchedFolders(page);
await page.waitForTimeout(2000);
const count = await getIDBFolderCount(page);
expect(count).toBe(4);
});
});
// ---------------------------------------------------------------------------
// Test: Responsive / Accessibility basics
// ---------------------------------------------------------------------------
test.describe("Watched Folders — Accessibility", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await navigateToWatchedFolders(page);
await page.waitForTimeout(1500);
});
test("New folder button is focusable via Tab", async ({ page }) => {
// Tab through the page until we reach the New folder button
for (let i = 0; i < 30; i++) {
await page.keyboard.press("Tab");
const focused = await page.evaluate(
() => document.activeElement?.textContent,
);
if (focused?.includes("New folder")) {
expect(true).toBe(true);
return;
}
}
// If we didn't find it in 30 tabs, that's concerning but not necessarily a failure
// (depends on page structure)
});
});
// ---------------------------------------------------------------------------
// Test: File Count Display
// ---------------------------------------------------------------------------
test.describe("Watched Folders — File Count", () => {
test.beforeEach(async ({ page }) => {
await setupApp(page);
await navigateToWatchedFolders(page);
await page.waitForTimeout(1500);
});
test("file count text is visible in both light and dark themes", async ({
page,
}) => {
// The file count should NOT have hardcoded white color (we fixed this)
// Verify by checking computed styles
const fileCountTexts = page.locator("text=file").first();
if (await fileCountTexts.isVisible({ timeout: 2000 }).catch(() => false)) {
const color = await fileCountTexts.evaluate(
(el) => window.getComputedStyle(el).color,
);
// Should not be pure white (#fff = rgb(255, 255, 255))
// In light theme, it should be a dark color
expect(color).toBeDefined();
}
});
});
@@ -0,0 +1,207 @@
import fs from "fs";
import path from "path";
import ts from "typescript";
import { describe, expect, test } from "vitest";
import { parse } from "smol-toml";
const REPO_ROOT = path.join(__dirname, "../../../..");
const SRC_ROOT = path.join(__dirname, "../..");
const EN_GB_FILE = path.join(
__dirname,
"../../../public/locales/en-GB/translation.toml",
);
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
const IGNORED_FILE_PATTERNS = [
/\.d\.ts$/,
/\.test\./,
/\.spec\./,
/\.stories\./,
];
/**
* Keys that look unused to the heuristic but are genuinely used: keep them.
* These are families assembled at runtime, so no static fragment ever reaches
* source code for the literal/template matching to catch. Add a regex here
* (with a comment naming the runtime usage) rather than teaching the test
* about specific component internals. For a single key, anchor it: /^a\.b$/.
*/
const IGNORED_KEY_PATTERNS: RegExp[] = [
// SignSettings / SavedSignaturesSection look up every key as
// t(`${translationScope}.${key}`); the scope ("sign" | "addText" |
// "addImage") and the relative key only ever exist as separate literals.
/^(sign|addText|addImage)\./,
// SettingsSearchBar builds its search index by loading whole subtrees via
// t(prefix, { returnObjects: true }); the leaf keys never appear in source.
/^admin\.settings\./,
/^settings\./,
/^account\./,
];
const flattenKeys = (
node: unknown,
prefix = "",
acc = new Set<string>(),
): Set<string> => {
if (!node || typeof node !== "object" || Array.isArray(node)) {
if (prefix) {
acc.add(prefix);
}
return acc;
}
for (const [childKey, value] of Object.entries(
node as Record<string, unknown>,
)) {
const next = prefix ? `${prefix}.${childKey}` : childKey;
flattenKeys(value, next, acc);
}
return acc;
};
const listSourceFiles = (): string[] => {
const files = ts.sys.readDirectory(
SRC_ROOT,
[".ts", ".tsx", ".js", ".jsx"],
undefined,
["**/*"],
);
return files
.filter(
(file) =>
!file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)),
)
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
};
const getScriptKind = (file: string): ts.ScriptKind => {
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX;
if (file.endsWith(".ts")) return ts.ScriptKind.TS;
if (file.endsWith(".jsx")) return ts.ScriptKind.JSX;
return ts.ScriptKind.JS;
};
/**
* Walk each file's AST and collect every template literal whose static parts
* could plausibly form a dotted translation key. Each shape replaces ${...}
* interpolations with `*`, e.g. `tools.${id}.title` becomes `tools.*.title`.
*
* We deliberately collect *all* template literals (not just those at t()
* call sites), because keys are often built up in helpers, constants or
* config objects and only passed to t() somewhere far away. A shape only
* counts if it carries at least one identifier-like static fragment though,
* so generic templates like `${name}.${ext}` (shape `*.*`) are discarded.
*
* Using the AST (rather than a backtick-pair regex) is important: source
* files contain large multi-line templates with embedded CSS/HTML and
* nested interpolations that confuse regex-based pairing.
*/
const extractTemplateShapesFromFile = (
file: string,
acc: Set<string>,
): void => {
const code = fs.readFileSync(file, "utf8");
if (!code.includes("${")) return;
const sourceFile = ts.createSourceFile(
file,
code,
ts.ScriptTarget.Latest,
false,
getScriptKind(file),
);
const visit = (node: ts.Node): void => {
if (ts.isTemplateExpression(node)) {
let shape = node.head.text;
for (const span of node.templateSpans) {
shape += "*";
shape += span.literal.text;
}
if (
shape.includes(".") &&
/[A-Za-z0-9_-]/.test(shape.replace(/\*/g, ""))
) {
acc.add(shape);
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
};
const shapeToMatcher = (shape: string): RegExp => {
// Each * stands in for one runtime-supplied path segment. We use `[^.]+`
// (not `.+`) so a one-variable interpolation doesn't accidentally span
// multiple key levels. If a real interpolation does carry a multi-segment
// string, the IGNORED_KEY_PATTERNS list is the escape hatch.
const escaped = shape
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join("[^.]+");
return new RegExp(`^${escaped}$`);
};
const isIgnored = (key: string): boolean => {
return IGNORED_KEY_PATTERNS.some((re) => re.test(key));
};
describe("Unused translation coverage", () => {
test(
"fails if any en-GB translation key has no source references",
{ timeout: 30_000 },
() => {
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
const enGb = parse(fs.readFileSync(EN_GB_FILE, "utf8"));
const availableKeys = Array.from(flattenKeys(enGb));
expect(availableKeys.length).toBeGreaterThan(100); // sanity check
const sourceFiles = listSourceFiles();
expect(sourceFiles.length).toBeGreaterThan(0);
const source = sourceFiles
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
const shapes = new Set<string>();
for (const file of sourceFiles) {
extractTemplateShapesFromFile(file, shapes);
}
const shapeMatchers = Array.from(shapes).map(shapeToMatcher);
const unused = availableKeys.filter((key) => {
if (isIgnored(key)) return false;
// Direct: the full key text appears anywhere in source (catches
// static t() calls, i18nKey props, constants, and any other place
// the literal string sits in code or comments).
if (source.includes(key)) return false;
// Dynamic: the key matches a template-literal shape from source.
return !shapeMatchers.some((re) => re.test(key));
});
const localeRelative = path
.relative(REPO_ROOT, EN_GB_FILE)
.replace(/\\/g, "/");
// GitHub Annotations format so unused keys show up tagged on the
// translation file in CI.
for (const key of unused) {
process.stderr.write(
`::error file=${localeRelative}::Unused en-GB translation: ${key}\n`,
);
}
expect(
unused,
`Found ${unused.length} unused en-GB translation key(s). ` +
`Remove them from ${localeRelative}, or (if the usage is too ` +
`dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` +
`in this test.`,
).toEqual([]);
},
);
});
@@ -0,0 +1,63 @@
/**
* Types for Watched Folders (a.k.a. Watched Folders) functionality.
*
* Server-backed persistence and server-side processing fields are intentionally
* not in this type — they land in a follow-up PR.
*/
export interface WatchedFolder {
id: string;
name: string;
description: string;
automationId: string; // FK → AutomationConfig.id
icon: string; // icon name string
accentColor: string; // hex e.g. '#3b82f6'
createdAt: string;
updatedAt: string;
order?: number;
isDefault?: boolean;
isPaused?: boolean;
maxRetries?: number; // 0 = disabled; default 3
retryDelayMinutes?: number; // default 5
outputMode?: "new_file" | "new_version"; // default: 'new_file'
outputName?: string; // output filename prefix/suffix
outputNamePosition?: "prefix" | "suffix" | "auto-number"; // default: 'prefix'
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
inputSource?: "idb" | "local-folder";
}
export interface FolderFileMetadata {
addedAt: Date;
status: "pending" | "processing" | "processed" | "error";
processedAt?: Date;
/** All output file ids produced by this run — references stirling-pdf-files */
displayFileIds?: string[];
/** First output file id — kept for backwards compat with existing records */
displayFileId?: string;
/** True when the folder created this file from a disk drop and therefore owns it.
* False / absent when the file came from the shared sidebar store — do NOT delete on folder removal. */
ownedByFolder?: boolean;
errorMessage?: string;
failedAttempts?: number;
nextRetryAt?: number; // ms timestamp — set when an automatic retry is scheduled
lastFailedAt?: Date;
name?: string; // original filename
}
export interface FolderRecord {
folderId: string;
files: Record<string, FolderFileMetadata>;
lastUpdated: number;
}
export interface WatchedFolderRunEntry {
inputFileId: string;
/** First output file id */
displayFileId: string;
/** All output file ids produced by this run — kept in sync with FolderFileMetadata.displayFileIds */
displayFileIds?: string[];
/** When this run completed — used for TTL-based "done" status */
processedAt?: Date;
status: "processing" | "processed";
}
@@ -0,0 +1,31 @@
/**
* Feature detection for the File System Access API.
*
* Browser support matrix (as of 2026):
* Chrome/Edge: full support — showDirectoryPicker, createWritable, queryPermission
* Firefox: showDirectoryPicker + read-only iteration only; no createWritable, no queryPermission
* Safari 15.2+: showDirectoryPicker + read only; no createWritable
*/
/** True when the browser can pick a directory and read files from it. */
export const canReadLocalFolder: boolean =
typeof window !== "undefined" &&
typeof (window as any).showDirectoryPicker === "function";
/** True when the browser supports writing files (createWritable). Requires Chrome/Edge. */
export const canWriteLocalFolder: boolean =
canReadLocalFolder &&
typeof FileSystemFileHandle !== "undefined" &&
typeof (FileSystemFileHandle.prototype as any).createWritable === "function";
/** True when the browser supports permission querying across sessions (queryPermission). */
export const canPersistFsPermission: boolean =
canReadLocalFolder &&
typeof FileSystemHandle !== "undefined" &&
typeof (FileSystemHandle.prototype as any).queryPermission === "function";
export const FS_READ_UNSUPPORTED_MSG =
"Your browser does not support the File System Access API. Use Chrome or Edge.";
export const FS_WRITE_UNSUPPORTED_MSG =
"Your browser cannot write to local folders. Use Chrome or Edge for this feature.";