From e2536daeb80b5f02ce94a0bf767e26f464534a7c Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:18:14 +0100 Subject: [PATCH] Feature/v2/smartfolders rebuild (#6480) Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../public/locales/en-GB/translation.toml | 182 +- frontend/editor/public/sw-folder-retry.js | 88 + .../src/core/components/AppProviders.tsx | 5 +- .../src/core/components/layout/Workbench.tsx | 6 +- .../core/components/shared/FileSidebar.tsx | 143 +- .../components/shared/FileSidebarFileItem.css | 64 +- .../components/shared/FileSidebarFileItem.tsx | 86 +- .../core/components/shared/WorkbenchBar.tsx | 44 +- .../tools/automate/AutomationCreation.tsx | 188 +- .../watchedFolders/watchedFolderDragState.ts | 23 + .../editor/src/core/constants/featureFlags.ts | 14 + .../src/core/contexts/FolderFileContext.tsx | 17 + .../src/core/hooks/useAllWatchedFolders.ts | 33 + .../src/core/hooks/useCardModalAnimation.ts | 72 + .../src/core/hooks/useFolderMembership.ts | 57 + .../core/services/watchedFolderFileStorage.ts | 214 ++ .../src/core/services/watchedFolderStorage.ts | 168 ++ frontend/editor/src/core/styles/theme.css | 4 + .../core/tests/live/watched-folders.spec.ts | 577 ++++ .../editor/src/core/types/watchedFolders.ts | 63 + .../src/core/utils/fsAccessCapability.ts | 31 + frontend/editor/src/proprietary/App.tsx | 3 + .../watchedFolders/CardExpansionModal.tsx | 278 ++ .../DeleteFolderConfirmModal.tsx | 56 + .../watchedFolders/FilePreviewModal.tsx | 101 + .../components/watchedFolders/IconPicker.tsx | 7 + .../components/watchedFolders/StatCard.tsx | 72 + .../watchedFolders/WatchedFolderCard.tsx | 159 ++ .../watchedFolders/WatchedFolderHomePage.tsx | 679 +++++ .../WatchedFolderManagementModal.tsx | 911 ++++++ .../watchedFolders/WatchedFolderSection.tsx | 169 ++ .../WatchedFolderWorkbenchView.tsx | 2442 +++++++++++++++++ .../watchedFolders/WatchedFolders.css | 254 ++ .../WatchedFoldersRegistration.tsx | 54 + .../src/proprietary/constants/featureFlags.ts | 15 + .../contexts/FolderFileContext.tsx | 75 + .../proprietary/data/watchedFolderPresets.ts | 120 + .../proprietary/hooks/useFolderAutomation.ts | 416 +++ .../src/proprietary/hooks/useFolderData.ts | 137 + .../proprietary/hooks/useFolderOutputIds.ts | 47 + .../proprietary/hooks/useFolderRunState.ts | 58 + .../proprietary/hooks/useFolderRunStatuses.ts | 108 + .../proprietary/hooks/useLocalFolderPoller.ts | 154 ++ .../hooks/useWatchedFolderUrlSync.ts | 199 ++ .../proprietary/hooks/useWatchedFolders.ts | 112 + .../services/folderDirectoryHandleStorage.ts | 160 ++ .../services/folderRetryScheduleStorage.ts | 166 ++ .../services/folderRunStateStorage.ts | 154 ++ .../services/folderSeenFilesStorage.ts | 84 + 49 files changed, 9166 insertions(+), 103 deletions(-) create mode 100644 frontend/editor/public/sw-folder-retry.js create mode 100644 frontend/editor/src/core/components/watchedFolders/watchedFolderDragState.ts create mode 100644 frontend/editor/src/core/constants/featureFlags.ts create mode 100644 frontend/editor/src/core/contexts/FolderFileContext.tsx create mode 100644 frontend/editor/src/core/hooks/useAllWatchedFolders.ts create mode 100644 frontend/editor/src/core/hooks/useCardModalAnimation.ts create mode 100644 frontend/editor/src/core/hooks/useFolderMembership.ts create mode 100644 frontend/editor/src/core/services/watchedFolderFileStorage.ts create mode 100644 frontend/editor/src/core/services/watchedFolderStorage.ts create mode 100644 frontend/editor/src/core/tests/live/watched-folders.spec.ts create mode 100644 frontend/editor/src/core/types/watchedFolders.ts create mode 100644 frontend/editor/src/core/utils/fsAccessCapability.ts create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/DeleteFolderConfirmModal.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/FilePreviewModal.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/IconPicker.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderWorkbenchView.tsx create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css create mode 100644 frontend/editor/src/proprietary/components/watchedFolders/WatchedFoldersRegistration.tsx create mode 100644 frontend/editor/src/proprietary/constants/featureFlags.ts create mode 100644 frontend/editor/src/proprietary/contexts/FolderFileContext.tsx create mode 100644 frontend/editor/src/proprietary/data/watchedFolderPresets.ts create mode 100644 frontend/editor/src/proprietary/hooks/useFolderAutomation.ts create mode 100644 frontend/editor/src/proprietary/hooks/useFolderData.ts create mode 100644 frontend/editor/src/proprietary/hooks/useFolderOutputIds.ts create mode 100644 frontend/editor/src/proprietary/hooks/useFolderRunState.ts create mode 100644 frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts create mode 100644 frontend/editor/src/proprietary/hooks/useLocalFolderPoller.ts create mode 100644 frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts create mode 100644 frontend/editor/src/proprietary/hooks/useWatchedFolders.ts create mode 100644 frontend/editor/src/proprietary/services/folderDirectoryHandleStorage.ts create mode 100644 frontend/editor/src/proprietary/services/folderRetryScheduleStorage.ts create mode 100644 frontend/editor/src/proprietary/services/folderRunStateStorage.ts create mode 100644 frontend/editor/src/proprietary/services/folderSeenFilesStorage.ts diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 719509b71..39db57af9 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3203,7 +3203,7 @@ creditsRemaining = "{{current}} of {{total}} remaining" creditsThisMonth = "Monthly credits" current = "Current Plan" customPricing = "Custom" -customSmartFolders = "Custom Smart Folders" +customWatchedFolders = "Custom Watched Folders" dedicatedSupportSlas = "Dedicated support & SLAs" enterpriseSubscription = "Enterprise" everythingInCredits = "Everything in Credits, plus:" @@ -3511,6 +3511,7 @@ addFiles = "Add Files" [fileManager] active = "Active" +addToWatchedFolder = "Add to Watched Folder" addToUpload = "Add to Upload" changesNotUploaded = "Changes not uploaded" clearAll = "Clear All" @@ -5773,6 +5774,7 @@ label = "SMTP Username" [quickAccess] access = "Access" +watchedFolders = "Watched Folders" accessAddPerson = "Add another person" accessBack = "Back" accessCopyLink = "Copy link" @@ -8150,6 +8152,184 @@ confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" +[watchedFolders] +alreadyInFolder = "Already in this folder" +deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." +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 watched folders yet" +sidebarTitle = "Watched Folders" +title = "Watched Folders" +sidebarFiles = "My Files" + +[watchedFolders.modal] +automation = "Automation" +automationSaved = "Automation saved" +createFolder = "Create Folder" +stepsSaved = "Steps saved — click Create Folder to finish" +automationRequired = "Add at least one configured step before saving." +color = "Accent color" +createTitle = "New Watched Folder" +description = "Description" +descriptionPlaceholder = "What does this folder do?" +editTitle = "Edit Watched Folder" +icon = "Icon" +name = "Folder name" +namePlaceholder = "My Watched Folder" +nameRequired = "Folder name is required" +nameTooLong = "Folder name must be 50 characters or less" +saveChanges = "Save Changes" +saveFailed = "Failed to save folder. Please try again." +automationNameFallback = "Watched Folder automation" +retryLabel = "Auto-retry" +maxRetries = "Max retries" +maxRetriesDesc = "0 to disable" +retryDelay = "Delay (minutes)" +outputLabel = "Output" +outputModeVersion = "Replace original?" +outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" +outputModeNewDesc = "Output is saved as a new separate file" +outputName = "Output filename prefix" +outputNameSuffix = "Suffix" +outputNamePlaceholderVersion = "Same as original" +sectionFolder = "Folder" +sectionSourceOutput = "Source & Output" +sectionSteps = "Steps" +inputSource = "Input source" +inputSourceBrowser = "Browser — drop files here" +inputSourceLocal = "Local folder (auto-scan)" +inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" +inputFolder = "Input folder" +inputFolderNotChosen = "No folder chosen — required for auto-scan" +changeFolder = "Change" +chooseFolder = "Choose" +clearFolder = "Clear" +autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." +localOutputFolder = "Local output folder" +outputFolderUnsupported = "Not supported in this browser" +outputFolderNotSet = "Not set — outputs stay in app" +advanced = "Advanced" +replaceOriginal = "Replace original file" +autoNumber = "Auto-number" +autoNumberExample = "e.g. document.pdf → document (1).pdf" +outputNamePrefix = "Filename prefix" +positionPrefix = "Prefix" +positionSuffix = "Suffix" + +[watchedFolders.home] +create = "Create your first Watched Folder" +dropHere = "Drop to process" +editFolder = "Edit folder" +empty = "No watched folders yet" +file = "file" +files = "files" +openFolder = "Open folder" +title = "Watched Folders" +subtitle = "Folders that automatically process PDFs with your configured pipeline" +emptyTitle = "Automate your PDF workflows" +emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." +addAnother = "Add another Watched Folder" +addAnotherDesc = "Automatically process files with a new pipeline" +resume = "Resume" +pause = "Pause" +deleteFolder = "Delete folder" +noSteps = "No automation steps configured" + +[watchedFolders.card] +edit = "Edit folder" +delete = "Delete folder" + +[watchedFolders.status] +done = "Done" +processing = "Processing" +paused = "Paused" +active = "Active" + +[watchedFolders.howItWorks] +title = "How Watched Folders work" +step1Title = "Drop files" +step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" +step2Title = "Pipeline runs" +step2Desc = "Your configured tools process each file automatically" +step3Title = "Output ready" +step3Desc = "Download processed files from inside the folder" + +[watchedFolders.workbench] +addFiles = "Add files" +inputs = "Inputs" +outputs = "Outputs" +failed = "Failed" +dataSaved = "Saved" +running = "Running" +dropToProcess = "Drop to process" +activity = "Activity" +noActivity = "No activity yet — drop a PDF to start" +noActivityMatch = "No matching activity" +retryAll = "Retry all" +retryIn = "retry in {{count}}m" +retryingSoon = "retrying…" +pause = "Pause" +resume = "Resume" +dayRunning = "day running" +daysRunning = "days running" +sortNewest = "Newest" +sortOldest = "Oldest" +sortNameAsc = "A → Z" +sortNameDesc = "Z → A" +filterAll = "All" +filterPending = "Pending" +search = "Search…" +retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" +watching = "Watching: {{name}}" +noInputFolder = "No input folder — edit to configure" +countProcessing = "{{count}} processing" +countQueued = "{{count}} queued" +exportZip = "Export zip" +exportSeparately = "Export separately" +countSelected = "{{count}} selected" +selectAll = "Select all {{count}}" +delete = "Delete" +clearSelection = "Clear selection" +preview = "Preview" +export = "Export" +previewInput = "Preview input" +previewOutput = "Preview output" +directionIn = "in" +directionOut = "out" +queuedWaiting = "Queued — waiting to run" +allTime = "All time" +queued = "Queued" +inputSize = "Input size" +filesProcessedOverTime = "Files processed over time" +legendComplete = "Complete" +chartTooltipComplete = "{{count}} complete" +chartTooltipFailed = "{{count}} failed" +removeEntry = "Remove entry" +removeEntries = "Remove {{count}} entries" +deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." +cancel = "Cancel" +removeNotificationsOnly = "Remove notifications only" +deleteOutputs = "Delete outputs" +previewLoadFailed = "Could not load file preview." + +[watchedFolders.actions] +back = "Back" +dismiss = "Dismiss" +retry = "Retry" +view = "View" +download = "Download" +downloadInput = "Download input" +downloadOutput = "Download output" +collapse = "Collapse" +expand = "Expand" + +[watchedFolders.time] +justNow = "just now" +minutesAgo = "{{count}}m ago" +hoursAgo = "{{count}}h ago" +daysAgo = "{{count}}d ago" [addStamp] tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" diff --git a/frontend/editor/public/sw-folder-retry.js b/frontend/editor/public/sw-folder-retry.js new file mode 100644 index 000000000..fbf0ed1fd --- /dev/null +++ b/frontend/editor/public/sw-folder-retry.js @@ -0,0 +1,88 @@ +/** + * Service worker for Watched Folder retry scheduling. + * + * Reads the earliest pending retry from IndexedDB and sets a setTimeout for it. + * When the timer fires it posts PROCESS_DUE_RETRIES to all window clients so + * the main thread can atomically claim and process the due entries. + * + * Limitations: + * - Browsers may terminate idle service workers after ~30 s. The main thread + * therefore also drains due retries on mount and on visibilitychange as a + * fallback — no retries are lost, they may just fire slightly late. + * - Multiple clients each post a SCHEDULE_RETRY message; the SW deduplicates + * by resetting the timer each time, so only one notification is sent. + */ + +const DB_NAME = "stirling-pdf-retry-schedule"; +const STORE_NAME = "retries"; + +let retryTimer = null; + +self.addEventListener("install", () => self.skipWaiting()); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim().then(scheduleNextTimer)); +}); + +self.addEventListener("message", (event) => { + if (event.data?.type === "SCHEDULE_RETRY") { + scheduleNextTimer(); + } +}); + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(new Error("SW: failed to open retry DB")); + // Create store if this SW activates before the main thread has opened the DB + req.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "id" }); + store.createIndex("dueAt", "dueAt", { unique: false }); + } + }; + }); +} + +async function getEarliestDueAt() { + try { + const db = await openDB(); + return new Promise((resolve) => { + const tx = db.transaction([STORE_NAME], "readonly"); + const req = tx.objectStore(STORE_NAME).index("dueAt").openCursor(); + req.onsuccess = () => resolve(req.result ? req.result.value.dueAt : null); + req.onerror = () => resolve(null); + }); + } catch { + return null; + } +} + +async function notifyClients() { + const clients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + for (const client of clients) { + client.postMessage({ type: "PROCESS_DUE_RETRIES" }); + } +} + +async function scheduleNextTimer() { + if (retryTimer !== null) { + clearTimeout(retryTimer); + retryTimer = null; + } + const earliest = await getEarliestDueAt(); + if (earliest === null) return; + + const delay = Math.max(0, earliest - Date.now()); + retryTimer = setTimeout(async () => { + retryTimer = null; + await notifyClients(); + // Re-schedule for any remaining entries that were not yet due + await scheduleNextTimer(); + }, delay); +} diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 6c0010945..d9f4864d8 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -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({ - {children} + + {children} + diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index c0928d7fe..9848c86f3 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -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 && (
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 4354f54a2..c11ff76e8 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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( 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( 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( activeFileId, requestNavigation, isMultiTool, + isWatchedFoldersActive, + activeWatchedFolderId, + setCustomWorkbenchViewData, ], ); @@ -676,6 +759,32 @@ const FileSidebar = forwardRef( )} + {/* Watched Folders entry */} + {WATCHED_FOLDERS_ENABLED && ( +
e.key === "Enter" && openWatchedFolders()} + aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")} + style={ + isWatchedFoldersActive + ? { backgroundColor: "var(--active-bg)" } + : undefined + } + > + + {!collapsed && ( + + {t("watchedFolders.sidebarTitle", "Watched Folders")} + + )} +
+ )} + {/* Files section - always visible when expanded */} {!collapsed && (
@@ -716,6 +825,34 @@ const FileSidebar = forwardRef( (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 => !!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( 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} /> ); })} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 0b6497823..3529f0165 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -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) ---- */ diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index e08188920..c03930864 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -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 ; } +/** 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} - - {dateLabel} - {dateLabel && typeLabel ? " · " : ""} - {typeLabel} + + + {dateLabel} + {dateLabel && typeLabel ? " · " : ""} + {typeLabel} + + {folders.length > 0 && ( + + {visibleFolders.map((folder) => ( + + { + e.stopPropagation(); + onFolderClick?.(folder.id); + }} + > + + + {folder.name} + + + + ))} + {overflowFolders.length > 0 && ( + f.name).join(", ")} + withArrow + position="top" + withinPortal + > + + +{overflowFolders.length} + + + )} + + )}