Watch folders set up

This commit is contained in:
Reece
2026-03-06 18:40:27 +00:00
parent 5d7c594a15
commit df65620d85
23 changed files with 951 additions and 190 deletions
+25 -5
View File
@@ -2972,6 +2972,7 @@ addFiles = "Add Files"
[fileManager]
active = "Active"
addToSmartFolder = "Add to Watch Folder"
addToUpload = "Add to Upload"
clearAll = "Clear All"
clearSelection = "Clear Selection"
@@ -4926,6 +4927,7 @@ help = "Help"
read = "Read"
reader = "Reader"
settings = "Settings"
watchFolders = "Watch Folders"
showMeAround = "Show me around"
sign = "Sign"
tours = "Tours"
@@ -6972,8 +6974,8 @@ deleteConfirmTitle = "Delete folder?"
defaultFolderWarning = "This is a default folder and will be recreated on next reload."
folderNotFound = "Folder not found"
newFolder = "New folder"
noFolders = "No smart folders yet"
title = "Smart Folders"
noFolders = "No watch folders yet"
title = "Watch Folders"
[smartFolders.modal]
automation = "Automation"
@@ -6981,17 +6983,31 @@ automationSaved = "Automation saved"
createFolder = "Create Folder"
stepsSaved = "Steps saved — click Create Folder to finish"
color = "Accent color"
createTitle = "New Smart Folder"
createTitle = "New Watch Folder"
description = "Description"
descriptionPlaceholder = "What does this folder do?"
editTitle = "Edit Smart Folder"
editTitle = "Edit Watch Folder"
icon = "Icon"
name = "Folder name"
namePlaceholder = "My Smart Folder"
namePlaceholder = "My Watch Folder"
nameRequired = "Folder name is required"
nameTooLong = "Folder name must be 50 characters or less"
saveChanges = "Save Changes"
[smartFolders.home]
create = "Create your first folder"
dropHere = "Drop to process"
editFolder = "Edit folder"
empty = "No watch folders yet"
file = "file"
files = "files"
openFolder = "Open folder"
title = "Watch Folders"
[smartFolders.status]
done = "Done"
processing = "Processing"
[smartFolders.workbench]
all = "All"
download = "Download"
@@ -7007,6 +7023,10 @@ processing = "Processing"
processingLog = "Processing log"
step = "Step {{step}}: {{operation}}"
sidebarFiles = "Your Files"
sidebarOutputFiles = "Output Files"
sidebarSelectFolder = "Folder Files"
[zipWarning]
cancel = "Cancel"
confirm = "Extract"
+2
View File
@@ -8,6 +8,7 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration";
// Import global styles
import "@app/styles/tailwind.css";
@@ -50,6 +51,7 @@ export default function App() {
<AppLayout>
<HomePage />
<Onboarding />
<SmartFoldersRegistration />
</AppLayout>
</AppProviders>
}
@@ -24,6 +24,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 { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -123,7 +124,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
@@ -16,6 +16,10 @@ import ToolChain from '@app/components/shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileManagement } from '@app/contexts/FileContext';
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { iconMap } from '@app/components/tools/automate/iconMap';
interface FileListItemProps {
file: StirlingFileStub;
@@ -48,6 +52,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
const { t } = useTranslation();
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { removeFiles } = useFileManagement();
const smartFolders = useAllSmartFolders();
const { setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
const isPdf = file.name?.toLowerCase().endsWith('.pdf') ?? false;
const showSmartFolders = isPdf && smartFolders.length > 0;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
@@ -260,6 +270,30 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{showSmartFolders && (
<>
<Menu.Divider />
<Menu.Label>{t('fileManager.addToSmartFolder', 'Add to Watch Folder')}</Menu.Label>
{smartFolders.map((folder) => {
const FolderItemIcon = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
return (
<Menu.Item
key={folder.id}
leftSection={<FolderItemIcon style={{ fontSize: 16, color: folder.accentColor }} />}
onClick={(e) => {
e.stopPropagation();
setCustomWorkbenchViewData('smartFolder', { folderId: folder.id, pendingFileId: file.id });
actions.setWorkbench('custom:smartFolder');
}}
>
{folder.name}
</Menu.Item>
);
})}
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
@@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
@@ -15,6 +16,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
import FolderSpecialRoundedIcon from '@mui/icons-material/FolderSpecialRounded';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
@@ -34,8 +36,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const location = useLocation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability, setCustomWorkbenchViewData } = useToolWorkflow();
const { hasUnsavedChanges, workbench } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
@@ -61,9 +63,13 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}, [location.pathname]);
useEffect(() => {
if (workbench === SMART_FOLDER_WORKBENCH_ID) {
setActiveButton('watchFolders');
return;
}
const next = getActiveNavButton(selectedToolKey, readerMode);
setActiveButton(next);
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode, workbench]);
const handleFilesButtonClick = () => {
openFilesModal();
@@ -158,6 +164,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
return availability?.available !== false;
}), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]);
const isWatchFoldersActive = workbench === SMART_FOLDER_WORKBENCH_ID;
const middleButtons: ButtonConfig[] = [
{
id: 'files',
@@ -168,6 +176,19 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
type: 'modal',
onClick: handleFilesButtonClick
},
{
id: 'watchFolders',
name: t("quickAccess.watchFolders", "Watch Folders"),
icon: <FolderSpecialRoundedIcon style={{ width: '1.25rem', height: '1.25rem' }} />,
isRound: true,
size: 'md',
type: 'navigation',
onClick: () => {
setActiveButton('watchFolders');
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
navigationActions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
}
},
];
//TODO: Activity
//{
@@ -0,0 +1,7 @@
/**
* Icon picker for Smart Folder create/edit.
* Re-exports IconSelector from the automate tools so smart folder components
* don't need to reach into the automate tools directory.
*/
export { default as IconPicker } from '@app/components/tools/automate/IconSelector';
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { Box, Button, Text, ActionIcon, Group, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { SmartFolder } from '@app/types/smartFolders';
import { FolderRunStatus } from '@app/hooks/useFolderRunStatuses';
import { iconMap } from '@app/components/tools/automate/iconMap';
interface SmartFolderCardProps {
folder: SmartFolder;
isActive: boolean;
status: FolderRunStatus;
onSelect: () => void;
onEdit: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
}
export function SmartFolderCard({ folder, isActive, status, onSelect, onEdit, onDelete }: SmartFolderCardProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const IconComponent = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
return (
<Box
className="tool-button-container"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Button
variant={isActive ? 'light' : 'subtle'}
className="tool-button"
fullWidth
justify="flex-start"
px="sm"
leftSection={
<Box
style={{
width: 18,
height: 18,
borderRadius: '50%',
backgroundColor: `${folder.accentColor}22`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IconComponent style={{ fontSize: 11, color: folder.accentColor }} />
</Box>
}
rightSection={
isHovered ? (
<Group gap={2} onClick={(e) => e.stopPropagation()}>
<ActionIcon
size="xs"
variant="subtle"
onClick={onEdit}
aria-label={t('smartFolders.card.edit', 'Edit folder')}
>
<EditIcon style={{ fontSize: 11 }} />
</ActionIcon>
{!folder.isDefault && (
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={onDelete}
aria-label={t('smartFolders.card.delete', 'Delete folder')}
>
<DeleteIcon style={{ fontSize: 11 }} />
</ActionIcon>
)}
</Group>
) : status === 'processing' ? (
<Loader size={10} color={folder.accentColor} />
) : status === 'done' ? (
<CheckCircleIcon style={{ fontSize: 12, color: 'var(--mantine-color-teal-6)' }} />
) : null
}
onClick={onSelect}
>
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</Text>
</Button>
</Box>
);
}
@@ -0,0 +1,367 @@
import { useState, useCallback, useEffect } from 'react';
import { Box, Text, Stack, Group, ActionIcon, Badge, Button, Loader, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import EditIcon from '@mui/icons-material/Edit';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useSmartFolders } from '@app/hooks/useSmartFolders';
import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses';
import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders';
import { AutomationConfig } from '@app/types/automation';
import { iconMap } from '@app/components/tools/automate/iconMap';
import { automationStorage } from '@app/services/automationStorage';
import { folderStorage } from '@app/services/folderStorage';
import { folderRunStateStorage } from '@app/services/folderRunStateStorage';
import { executeAutomationSequence } from '@app/utils/automationExecutor';
import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFolderManagementModal';
import { DeleteFolderConfirmModal } from '@app/components/smartFolders/DeleteFolderConfirmModal';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
// Humanise an operation key like "compress-pdf" → "Compress PDF"
function humaniseOp(op: string): string {
return op
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
interface FolderCardProps {
folder: SmartFolder;
status: 'idle' | 'processing' | 'done';
isProcessing: boolean;
onEdit: (folder: SmartFolder) => void;
onOpen: (folderId: string) => void;
onDropFiles: (folder: SmartFolder, files: File[]) => void;
}
function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles }: FolderCardProps) {
const { t } = useTranslation();
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
const [fileCount, setFileCount] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => {
automationStorage.getAutomation(folder.automationId).then(setAutomation);
const loadCount = () =>
folderStorage.getFolderData(folder.id).then((record) => {
setFileCount(record ? Object.keys(record.files).length : 0);
});
loadCount();
const unsub = folderStorage.onFolderChange((changedId) => {
if (changedId === folder.id) loadCount();
});
return unsub;
}, [folder.id, folder.automationId]);
const FolderIcon = iconMap[folder.icon as keyof typeof iconMap] ?? iconMap.FolderIcon;
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
onDropFiles(folder, Array.from(e.dataTransfer.files));
};
return (
<Box
style={{
border: `2px solid ${isDragOver ? folder.accentColor : 'var(--border-subtle)'}`,
borderRadius: 'var(--mantine-radius-md)',
padding: '0.875rem 1rem',
backgroundColor: isDragOver
? `${folder.accentColor}15`
: 'var(--bg-surface, var(--mantine-color-default))',
transition: 'border-color 0.15s, background-color 0.15s',
position: 'relative',
cursor: 'default',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Group align="flex-start" wrap="nowrap" gap="sm">
{/* Icon circle */}
<Box
style={{
width: 38,
height: 38,
borderRadius: '50%',
backgroundColor: `${folder.accentColor}22`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginTop: 2,
}}
>
<FolderIcon style={{ fontSize: 20, color: folder.accentColor }} />
</Box>
{/* Main content */}
<Stack gap={5} style={{ flex: 1, minWidth: 0 }}>
{/* Top row: name + badges + actions */}
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1} style={{ minWidth: 0 }}>
{folder.name}
</Text>
{fileCount > 0 && (
<Badge size="xs" variant="light" color="gray" style={{ flexShrink: 0 }}>
{fileCount} {fileCount === 1
? t('smartFolders.home.file', 'file')
: t('smartFolders.home.files', 'files')}
</Badge>
)}
{isProcessing && (
<Badge size="xs" color="blue" variant="dot" style={{ flexShrink: 0 }}>
{t('smartFolders.status.processing', 'Processing')}
</Badge>
)}
{status === 'done' && !isProcessing && (
<Badge size="xs" color="teal" variant="dot" style={{ flexShrink: 0 }}>
{t('smartFolders.status.done', 'Done')}
</Badge>
)}
</Group>
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
<ActionIcon
size="sm"
variant="subtle"
onClick={(e) => { e.stopPropagation(); onEdit(folder); }}
aria-label={t('smartFolders.home.editFolder', 'Edit folder')}
>
<EditIcon style={{ fontSize: 14 }} />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
onClick={(e) => { e.stopPropagation(); onOpen(folder.id); }}
aria-label={t('smartFolders.home.openFolder', 'Open folder')}
>
<OpenInNewIcon style={{ fontSize: 14 }} />
</ActionIcon>
</Group>
</Group>
{/* Description */}
{folder.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{folder.description}
</Text>
)}
{/* Operation pills */}
{automation && automation.operations.length > 0 && (
<Group gap={4} wrap="wrap">
{automation.operations.map((op, i) => (
<Badge
key={i}
size="xs"
variant="outline"
style={{ borderColor: folder.accentColor, color: folder.accentColor }}
>
{humaniseOp(op.operation)}
</Badge>
))}
</Group>
)}
</Stack>
</Group>
{/* Drop overlay */}
{isDragOver && (
<Box
style={{
position: 'absolute',
inset: 0,
borderRadius: 'var(--mantine-radius-md)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${folder.accentColor}20`,
pointerEvents: 'none',
}}
>
<Group gap="xs">
<UploadFileIcon style={{ fontSize: 18, color: folder.accentColor }} />
<Text fw={700} size="sm" style={{ color: folder.accentColor }}>
{t('smartFolders.home.dropHere', 'Drop to process')}
</Text>
</Group>
</Box>
)}
</Box>
);
}
export function SmartFolderHomePage() {
const { t } = useTranslation();
const { folders, loading, deleteFolder, refreshFolders } = useSmartFolders();
const statuses = useFolderRunStatuses(folders);
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editFolder, setEditFolder] = useState<SmartFolder | null>(null);
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(null);
const [processingFolderIds, setProcessingFolderIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<SmartFolder | null>(null);
const navigateToFolder = useCallback((folderId: string) => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
}, [setCustomWorkbenchViewData, actions]);
const handleEdit = useCallback(async (folder: SmartFolder) => {
setEditFolder(folder);
const automation = await automationStorage.getAutomation(folder.automationId);
setEditAutomation(automation);
setCreateModalOpen(true);
}, []);
const handleModalClose = () => {
setCreateModalOpen(false);
setEditFolder(null);
setEditAutomation(null);
};
const processFiles = useCallback(async (folder: SmartFolder, files: File[]) => {
const pdfs = files.filter(f => f.name.toLowerCase().endsWith('.pdf'));
if (pdfs.length === 0) return;
setProcessingFolderIds(prev => new Set([...prev, folder.id]));
try {
const automation: AutomationConfig | null = await automationStorage.getAutomation(folder.automationId);
if (!automation) return;
for (const file of pdfs) {
const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await folderStorage.addFileToFolder(folder.id, inputFileId, { status: 'processing' });
try {
const resultFiles = await executeAutomationSequence(automation, [file], toolRegistry);
const existingRuns = await folderRunStateStorage.getFolderRunState(folder.id);
const newRuns: SmartFolderRunEntry[] = [...existingRuns];
for (const resultFile of resultFiles) {
const outputId = `output-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await folderStorage.storeOutputFile(folder.id, outputId, resultFile, resultFile.name);
newRuns.push({ inputFileId, displayFileId: outputId, status: 'processed' });
}
await folderStorage.updateFileMetadata(folder.id, inputFileId, {
status: 'processed',
processedAt: new Date(),
});
await folderRunStateStorage.setFolderRunState(folder.id, newRuns);
} catch {
await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'error' });
}
}
} finally {
setProcessingFolderIds(prev => {
const next = new Set(prev);
next.delete(folder.id);
return next;
});
}
}, [toolRegistry]);
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
await deleteFolder(deleteTarget.id);
setDeleteTarget(null);
};
return (
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box
px="md"
py="sm"
style={{
borderBottom: '1px solid var(--border-subtle)',
backgroundColor: 'var(--bg-toolbar)',
flexShrink: 0,
}}
>
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('smartFolders.home.title', 'Watch Folders')}</Text>
<Button
size="xs"
variant="light"
leftSection={<AddIcon style={{ fontSize: 14 }} />}
onClick={() => setCreateModalOpen(true)}
>
{t('smartFolders.newFolder', 'New folder')}
</Button>
</Group>
</Box>
{/* Folder list */}
<ScrollArea style={{ flex: 1 }}>
<Box p="sm">
{loading ? (
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<Loader size="sm" />
</Box>
) : folders.length === 0 ? (
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem', gap: '1rem' }}>
<Text c="dimmed" ta="center" size="sm">
{t('smartFolders.home.empty', 'No watch folders yet')}
</Text>
<Button
variant="light"
size="xs"
leftSection={<AddIcon style={{ fontSize: 14 }} />}
onClick={() => setCreateModalOpen(true)}
>
{t('smartFolders.home.create', 'Create your first folder')}
</Button>
</Box>
) : (
<Stack gap="sm">
{folders.map((folder) => {
const status = statuses[folder.id] ?? 'idle';
const isProcessing = processingFolderIds.has(folder.id) || status === 'processing';
return (
<FolderCard
key={folder.id}
folder={folder}
status={status}
isProcessing={isProcessing}
onEdit={handleEdit}
onOpen={navigateToFolder}
onDropFiles={processFiles}
/>
);
})}
</Stack>
)}
</Box>
</ScrollArea>
<SmartFolderManagementModal
opened={createModalOpen}
editFolder={editFolder}
existingAutomation={editAutomation}
onClose={handleModalClose}
onSaved={refreshFolders}
/>
<DeleteFolderConfirmModal
opened={!!deleteTarget}
folder={deleteTarget}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteTarget(null)}
/>
</Box>
);
}
@@ -15,7 +15,7 @@ import {
import { useTranslation } from 'react-i18next';
import { SmartFolder } from '@app/types/smartFolders';
import { AutomationConfig, AutomationMode } from '@app/types/automation';
import IconSelector from '@app/components/tools/automate/IconSelector';
import { IconPicker as IconSelector } from '@app/components/smartFolders/IconPicker';
import AutomationCreation from '@app/components/tools/automate/AutomationCreation';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { smartFolderStorage } from '@app/services/smartFolderStorage';
@@ -119,8 +119,8 @@ export function SmartFolderManagementModal({
};
const title = isEditMode
? t('smartFolders.modal.editTitle', 'Edit Smart Folder')
: t('smartFolders.modal.createTitle', 'New Smart Folder');
? t('smartFolders.modal.editTitle', 'Edit Watch Folder')
: t('smartFolders.modal.createTitle', 'New Watch Folder');
return (
<Modal
@@ -136,7 +136,7 @@ export function SmartFolderManagementModal({
<Group gap="xs" align="flex-end">
<TextInput
label={t('smartFolders.modal.name', 'Folder name')}
placeholder={t('smartFolders.modal.namePlaceholder', 'My Smart Folder')}
placeholder={t('smartFolders.modal.namePlaceholder', 'My Watch Folder')}
value={name}
onChange={(e) => { setName(e.currentTarget.value); setNameError(''); }}
error={nameError}
@@ -197,7 +197,7 @@ export function SmartFolderManagementModal({
onComplete={handleAutomationComplete}
toolRegistry={toolRegistry}
hideMetadata
nameOverride={name.trim() || 'Smart Folder Automation'}
nameOverride={name.trim() || 'Watch Folder Automation'}
saveTriggerRef={automationSaveTrigger}
/>
@@ -1,25 +1,18 @@
import { useState, useEffect } from 'react';
import { Box, Button, Text, Stack, ActionIcon, Group, Loader } from '@mantine/core';
import { useState } from 'react';
import { Box, Button, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useSmartFolders } from '@app/hooks/useSmartFolders';
import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFolderManagementModal';
import { DeleteFolderConfirmModal } from '@app/components/smartFolders/DeleteFolderConfirmModal';
import { SmartFolderWorkbenchView } from '@app/components/smartFolders/SmartFolderWorkbenchView';
import { SmartFolderCard } from '@app/components/smartFolders/SmartFolderCard';
import { SmartFolder } from '@app/types/smartFolders';
import { AutomationConfig } from '@app/types/automation';
import { automationStorage } from '@app/services/automationStorage';
import { seedDefaultFolders } from '@app/data/smartFolderPresets';
import { iconMap } from '@app/components/tools/automate/iconMap';
const SMART_FOLDER_VIEW_ID = 'smartFolder';
const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder' as const;
import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
export function SmartFolderSection() {
const { t } = useTranslation();
@@ -28,28 +21,13 @@ export function SmartFolderSection() {
const [editAutomation, setEditAutomation] = useState<AutomationConfig | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SmartFolder | null>(null);
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const { folders, loading, deleteFolder, refreshFolders } = useSmartFolders();
const statuses = useFolderRunStatuses(folders);
const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, setCustomWorkbenchViewData } = useToolWorkflow();
const { setCustomWorkbenchViewData } = useToolWorkflow();
const { actions } = useNavigationActions();
useEffect(() => {
seedDefaultFolders();
}, []);
useEffect(() => {
registerCustomWorkbenchView({
id: SMART_FOLDER_VIEW_ID,
workbenchId: SMART_FOLDER_WORKBENCH_ID,
label: t('smartFolders.title', 'Smart Folders'),
component: SmartFolderWorkbenchView,
});
return () => unregisterCustomWorkbenchView(SMART_FOLDER_VIEW_ID);
}, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]);
const handleFolderClick = (folderId: string) => {
setActiveFolderId(folderId);
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
@@ -96,89 +74,42 @@ export function SmartFolderSection() {
overflow: 'hidden',
}}
>
{/* Section header — matches .tool-subcategory-row style */}
<Box px="sm" pt="xs" pb="2px" style={{ flexShrink: 0 }}>
<Box className="tool-subcategory-row">
<Text className="tool-subcategory-row-title">
{t('smartFolders.title', 'Smart Folders')}
<Text
className="tool-subcategory-row-title"
style={{ cursor: 'pointer' }}
onClick={() => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null });
actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID);
}}
>
{t('smartFolders.title', 'Watch Folders')}
</Text>
<Box className="tool-subcategory-row-rule" />
</Box>
</Box>
{/* Scrollable folder list */}
<Box className="tool-picker-scrollable" style={{ flex: 1, minHeight: 0 }}>
<Stack gap={0} pb="xs">
{!loading && folders.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="sm" px="sm">
{t('smartFolders.noFolders', 'No smart folders yet')}
{t('smartFolders.noFolders', 'No watch folders yet')}
</Text>
)}
{folders.map((folder) => {
const IconComponent = iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon;
const status = statuses[folder.id] ?? 'idle';
const isActive = activeFolderId === folder.id;
const isHovered = hoveredId === folder.id;
{folders.map((folder) => (
<SmartFolderCard
key={folder.id}
folder={folder}
isActive={activeFolderId === folder.id}
status={statuses[folder.id] ?? 'idle'}
onSelect={() => handleFolderClick(folder.id)}
onEdit={(e) => handleEditFolder(e, folder)}
onDelete={(e) => handleDeleteClick(e, folder)}
/>
))}
return (
<Box
key={folder.id}
className="tool-button-container"
onMouseEnter={() => setHoveredId(folder.id)}
onMouseLeave={() => setHoveredId(null)}
>
<Button
variant={isActive ? 'light' : 'subtle'}
className="tool-button"
fullWidth
justify="flex-start"
px="sm"
leftSection={
<Box
style={{
width: 18,
height: 18,
borderRadius: '50%',
backgroundColor: `${folder.accentColor}22`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IconComponent style={{ fontSize: 11, color: folder.accentColor }} />
</Box>
}
rightSection={
isHovered ? (
<Group gap={2} onClick={(e) => e.stopPropagation()}>
<ActionIcon size="xs" variant="subtle" onClick={(e) => handleEditFolder(e, folder)}>
<EditIcon style={{ fontSize: 11 }} />
</ActionIcon>
{!folder.isDefault && (
<ActionIcon size="xs" variant="subtle" color="red" onClick={(e) => handleDeleteClick(e, folder)}>
<DeleteIcon style={{ fontSize: 11 }} />
</ActionIcon>
)}
</Group>
) : status === 'processing' ? (
<Loader size={10} color={folder.accentColor} />
) : status === 'done' ? (
<CheckCircleIcon style={{ fontSize: 12, color: 'var(--mantine-color-teal-6)' }} />
) : null
}
onClick={() => handleFolderClick(folder.id)}
>
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</Text>
</Button>
</Box>
);
})}
{/* New folder button */}
<Button
variant="subtle"
className="tool-button"
@@ -0,0 +1,69 @@
import { useState, useCallback, useEffect } from 'react';
import { Box, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
import FileListArea from '@app/components/fileManager/FileListArea';
import { useFileManager } from '@app/hooks/useFileManager';
import { useAllFiles } from '@app/contexts/FileContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { StirlingFileStub } from '@app/types/fileContext';
import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
export function SmartFolderSidebarPanel() {
const { t } = useTranslation();
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const { fileIds: activeFileIds } = useAllFiles();
const { customWorkbenchViews, setCustomWorkbenchViewData } = useToolWorkflow();
const smartFolderView = customWorkbenchViews.find(v => v.id === SMART_FOLDER_VIEW_ID);
const folderId = smartFolderView?.data?.folderId ?? null;
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
useEffect(() => {
refreshRecentFiles();
}, [refreshRecentFiles]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
}, [handleRemoveFile, recentFiles]);
// Double-clicking a file sends it to the currently open folder (no-op on home page)
const handleFilesSelected = useCallback((files: StirlingFileStub[]) => {
if (files.length === 0 || !folderId) return;
files.forEach((file, i) => {
setTimeout(() => {
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId, pendingFileId: file.id });
}, i * 50);
});
}, [folderId, setCustomWorkbenchViewData]);
return (
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box px="sm" pt="xs" pb="xs" style={{ flexShrink: 0, borderBottom: '1px solid var(--border-subtle)' }}>
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t('smartFolders.sidebarFiles', 'Your Files')}
</Text>
</Box>
<FileManagerProvider
recentFiles={recentFiles}
onRecentFilesSelected={handleFilesSelected}
onNewFilesSelect={() => {}}
onClose={() => {}}
isFileSupported={(name) => name.toLowerCase().endsWith('.pdf')}
isOpen={true}
onFileRemove={handleRemoveFileByIndex}
modalHeight="100%"
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
activeFileIds={activeFileIds}
>
<FileListArea scrollAreaHeight="100%" scrollAreaStyle={{ flex: 1, minHeight: 0 }} />
</FileManagerProvider>
</Box>
);
}
@@ -18,15 +18,19 @@ import { useSmartFolders } from '@app/hooks/useSmartFolders';
import { useFolderData } from '@app/hooks/useFolderData';
import { useFolderRunState } from '@app/hooks/useFolderRunState';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration';
import { automationStorage } from '@app/services/automationStorage';
import { folderStorage } from '@app/services/folderStorage';
import { executeAutomationSequence } from '@app/utils/automationExecutor';
import { SmartFolderRunEntry } from '@app/types/smartFolders';
import { AutomationConfig } from '@app/types/automation';
import { iconMap } from '@app/components/tools/automate/iconMap';
import { fileStorage } from '@app/services/fileStorage';
import { FileId } from '@app/types/fileContext';
import { SmartFolderHomePage } from '@app/components/smartFolders/SmartFolderHomePage';
interface SmartFolderWorkbenchViewProps {
data: { folderId: string };
data: { folderId: string | null; pendingFileId?: string };
}
interface StepProgress {
@@ -41,7 +45,7 @@ type FilterTab = 'all' | 'pending' | 'processing' | 'processed';
export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps) {
const { folderId } = data;
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow();
const { folders } = useSmartFolders();
const folder = folders.find(f => f.id === folderId);
@@ -62,6 +66,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const [filterTab, setFilterTab] = useState<FilterTab>('all');
const [outputFiles, setOutputFiles] = useState<{ fileId: string; name: string; blob: Blob }[]>([]);
const processingRef = useRef<Set<string>>(new Set());
const handledPendingRef = useRef<string | null>(null);
// Load output files
useEffect(() => {
@@ -154,6 +159,18 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
[addFile, runAutomation]
);
// Auto-process a file passed via workbench data (e.g. from sidebar or "Add to Smart Folder")
useEffect(() => {
const { pendingFileId } = data;
if (!pendingFileId || handledPendingRef.current === pendingFileId) return;
handledPendingRef.current = pendingFileId;
// Clear pendingFileId from workbench data so re-mounting doesn't replay it
setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId });
fileStorage.getStirlingFile(pendingFileId as FileId).then((stirlingFile) => {
if (stirlingFile) handleFiles([stirlingFile]);
});
}, [data, folderId, handleFiles, setCustomWorkbenchViewData]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
@@ -195,6 +212,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
return fileIds;
})();
// Home page: no specific folder selected
if (!folderId) {
return <SmartFolderHomePage />;
}
const FolderIcon = folder ? (iconMap[folder.icon as keyof typeof iconMap] || iconMap.SettingsIcon) : iconMap.SettingsIcon;
if (!folder) {
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { SmartFolderWorkbenchView } from '@app/components/smartFolders/SmartFolderWorkbenchView';
import { seedDefaultFolders } from '@app/data/smartFolderPresets';
export const SMART_FOLDER_VIEW_ID = 'smartFolder';
export const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder' as const;
export default function SmartFoldersRegistration() {
const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, clearCustomWorkbenchViewData } = useToolWorkflow();
// Keep refs to latest cleanup callbacks so the registration effect doesn't
// re-run (and tear down) when unregisterCustomWorkbenchView changes identity
// due to NavigationContext re-renders.
const unregisterRef = useRef(unregisterCustomWorkbenchView);
const clearRef = useRef(clearCustomWorkbenchViewData);
useEffect(() => { unregisterRef.current = unregisterCustomWorkbenchView; });
useEffect(() => { clearRef.current = clearCustomWorkbenchViewData; });
useEffect(() => {
seedDefaultFolders();
}, []);
useEffect(() => {
registerCustomWorkbenchView({
id: SMART_FOLDER_VIEW_ID,
workbenchId: SMART_FOLDER_WORKBENCH_ID,
label: 'Watch Folders',
component: SmartFolderWorkbenchView,
});
return () => {
clearRef.current(SMART_FOLDER_VIEW_ID);
unregisterRef.current(SMART_FOLDER_VIEW_ID);
};
}, [registerCustomWorkbenchView]);
return null;
}
@@ -19,6 +19,8 @@ import { useRightRail } from '@app/contexts/RightRailContext';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { SmartFolderSection } from '@app/components/smartFolders/SmartFolderSection';
import { SmartFolderSidebarPanel } from '@app/components/smartFolders/SmartFolderSidebarPanel';
import { useSmartFolderSidebar } from '@app/hooks/useSmartFolderSidebar';
// No props needed - component uses context
@@ -48,6 +50,8 @@ export default function ToolPanel() {
const { setAllRightRailButtonsDisabled } = useRightRail();
const { preferences, updatePreference } = usePreferences();
const { isActive: isSmartFolderSidebar } = useSmartFolderSidebar();
const isFullscreenMode = toolPanelMode === 'fullscreen';
const toolPickerVisible = !readerMode;
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
@@ -124,84 +128,94 @@ export default function ToolPanel() {
flexDirection: 'column'
}}
>
<div
className="tool-panel__search-row"
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
{!isMobile && leftPanelView === 'toolPicker' && (
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
>
<ActionIcon
variant="subtle"
radius="xl"
style={{ color: 'var(--right-rail-icon)' }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
{!isSmartFolderSidebar && (
<div
className="tool-panel__search-row"
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
{!isMobile && leftPanelView === 'toolPicker' && (
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? 'scaleX(-1)' : undefined }}
/>
</ActionIcon>
</Tooltip>
)}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t('toolPanel.placeholder', 'Choose a tool to get started')}
</div>
)}
</ScrollArea>
</div>
</div>
<ActionIcon
variant="subtle"
radius="xl"
style={{ color: 'var(--right-rail-icon)' }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
>
<DoubleArrowIcon
fontSize="small"
style={{ transform: isRTL ? 'scaleX(-1)' : undefined }}
/>
</ActionIcon>
</Tooltip>
)}
</div>
<div style={{ flex: '0 0 50%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SmartFolderSection />
</div>
)}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{isSmartFolderSidebar ? (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SmartFolderSidebarPanel />
</div>
) : (
<>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t('toolPanel.placeholder', 'Choose a tool to get started')}
</div>
)}
</ScrollArea>
</div>
</div>
)}
</div>
<div style={{ flex: '0 0 50%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SmartFolderSection />
</div>
</>
)}
</div>
</div>
)}
@@ -0,0 +1,59 @@
import { createContext, useContext, useState, ReactNode } from 'react';
import { useFolderData } from '@app/hooks/useFolderData';
import { FolderFileMetadata, FolderRecord } from '@app/types/smartFolders';
interface FolderFileContextValue {
activeFolderId: string | null;
setActiveFolderId: (id: string | null) => void;
folderRecord: FolderRecord | null;
fileIds: string[];
pendingFileIds: string[];
processingFileIds: string[];
processedFileIds: string[];
addFile: (fileId: string, metadata?: Partial<FolderFileMetadata>) => Promise<void>;
removeFile: (fileId: string) => Promise<void>;
updateFileMetadata: (fileId: string, updates: Partial<FolderFileMetadata>) => Promise<void>;
clearFolder: () => Promise<void>;
getFileMetadata: (fileId: string) => FolderFileMetadata | null;
isFileProcessed: (fileId: string) => boolean;
isFileProcessing: (fileId: string) => boolean;
}
const FolderFileContext = createContext<FolderFileContextValue | null>(null);
interface FolderFileContextProviderProps {
children: ReactNode;
}
// Inner component so useFolderData is only called once activeFolderId is set
function FolderFileContextInner({
activeFolderId,
setActiveFolderId,
children,
}: {
activeFolderId: string | null;
setActiveFolderId: (id: string | null) => void;
children: ReactNode;
}) {
const folderData = useFolderData(activeFolderId ?? '');
return (
<FolderFileContext.Provider value={{ activeFolderId, setActiveFolderId, ...folderData }}>
{children}
</FolderFileContext.Provider>
);
}
export function FolderFileContextProvider({ children }: FolderFileContextProviderProps) {
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
return (
<FolderFileContextInner activeFolderId={activeFolderId} setActiveFolderId={setActiveFolderId}>
{children}
</FolderFileContextInner>
);
}
export function useFolderFileContext(): FolderFileContextValue {
const ctx = useContext(FolderFileContext);
if (!ctx) throw new Error('useFolderFileContext must be used within FolderFileContextProvider');
return ctx;
}
@@ -0,0 +1,29 @@
/**
* 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 useSmartFolders instead.
*/
import { useState, useEffect } from 'react';
import { SmartFolder } from '@app/types/smartFolders';
import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage';
export function useAllSmartFolders(): SmartFolder[] {
const [folders, setFolders] = useState<SmartFolder[]>([]);
useEffect(() => {
const load = async () => {
try {
setFolders(await smartFolderStorage.getAllFolders());
} catch (err) {
console.error('Failed to load smart folders:', err);
}
};
load();
window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load);
}, []);
return folders;
}
@@ -0,0 +1,42 @@
/**
* Returns a map of fileId → folderId for all files currently in any smart folder.
* Used to verify folder-scoped file isolation — a file in this map is owned by a folder
* and must not appear in the global file context.
*/
import { useState, useEffect } from 'react';
import { folderStorage } from '@app/services/folderStorage';
import { useAllSmartFolders } from '@app/hooks/useAllSmartFolders';
export function useFolderMembership(): Map<string, string> {
const folders = useAllSmartFolders();
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>();
for (const folder of folders) {
try {
const record = await folderStorage.getFolderData(folder.id);
if (record) {
Object.keys(record.files).forEach(fileId => {
map.set(fileId, folder.id);
});
}
} catch {
// ignore individual folder failures
}
}
setMembership(map);
};
load();
}, [folders]);
return membership;
}
@@ -0,0 +1,8 @@
import { useNavigationState } from '@app/contexts/NavigationContext';
const SMART_FOLDER_WORKBENCH_ID = 'custom:smartFolder';
export function useSmartFolderSidebar() {
const { workbench } = useNavigationState();
return { isActive: workbench === SMART_FOLDER_WORKBENCH_ID };
}
@@ -11,7 +11,7 @@ interface RunStateRecord {
}
class FolderRunStateStorage {
private dbName = 'StirlingPDF_FolderRunState';
private dbName = 'stirling-pdf-folder-run-state';
private dbVersion = 1;
private storeName = 'runStates';
private db: IDBDatabase | null = null;
+1 -1
View File
@@ -15,7 +15,7 @@ interface OutputFileRecord {
}
class FolderStorage {
private dbName = 'StirlingPDF_FolderFiles';
private dbName = 'stirling-pdf-folder-files';
private dbVersion = 1;
private recordsStore = 'folderRecords';
private outputStore = 'folderOutputFiles';
@@ -7,7 +7,7 @@ import { SmartFolder } from '@app/types/smartFolders';
const STORAGE_CHANGE_EVENT = 'smart-folder-storage-changed';
class SmartFolderStorage {
private dbName = 'StirlingPDF_SmartFolders';
private dbName = 'stirling-pdf-smart-folders';
private dbVersion = 1;
private storeName = 'smartFolders';
private db: IDBDatabase | null = null;
+4
View File
@@ -191,6 +191,8 @@
--icon-files-color: #0A8BFF;
--icon-activity-bg: #D3E7F7;
--icon-activity-color: #0A8BFF;
--icon-watchFolders-bg: #F59E0B;
--icon-watchFolders-color: #FFFFFF;
--icon-config-bg: #9CA3AF;
--icon-config-color: #FFFFFF;
@@ -475,6 +477,8 @@
--icon-files-color: #EAEAEA;
--icon-activity-bg: #4B525A;
--icon-activity-color: #EAEAEA;
--icon-watchFolders-bg: #4B525A;
--icon-watchFolders-color: #EAEAEA;
--icon-config-bg: #4B525A;
--icon-config-color: #EAEAEA;
+2 -1
View File
@@ -12,7 +12,7 @@ import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration";
// Import global styles
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
@@ -64,6 +64,7 @@ export default function App() {
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
<SmartFoldersRegistration />
</AppLayout>
</AppProviders>
}