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

This commit is contained in:
Anthony Stirling
2026-05-27 12:52:46 +01:00
committed by GitHub
parent 4564ed5bec
commit d42b779644
90 changed files with 14720 additions and 663 deletions
+8 -8
View File
@@ -4,10 +4,10 @@ import { defineConfig, devices } from "@playwright/test";
* Stirling-PDF E2E Test Configuration
*
* The suite is split into two projects:
* - `stubbed` backend-free specs that mock `/api/v1/*` via `page.route()`.
* - `stubbed` - backend-free specs that mock `/api/v1/*` via `page.route()`.
* Safe to run in CI without the Spring Boot server. Lives in
* `src/core/tests/stubbed/**`.
* - `live` specs that require a real backend on `localhost:8080`
* - `live` - specs that require a real backend on `localhost:8080`
* (auth, admin mutation, real tool round-trips). Lives in
* `src/core/tests/live/**`.
*
@@ -35,7 +35,7 @@ export default defineConfig({
expect: { timeout: 10_000 },
use: {
baseURL: "http://localhost:5173",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
@@ -44,14 +44,14 @@ export default defineConfig({
},
projects: [
// Stubbed no backend required, chromium-only for CI speed
// Stubbed - no backend required, chromium-only for CI speed
{
name: "stubbed",
testDir: "./src/core/tests/stubbed",
use: chromiumViewport,
},
// Live setup runs once before the live suite to perform the real
// Live setup - runs once before the live suite to perform the real
// forced-password-change first-login flow against a freshly-booted
// backend. The live project depends on it.
{
@@ -61,7 +61,7 @@ export default defineConfig({
use: chromiumViewport,
},
// Live backend auth + admin-mutation + real-tool smoke
// Live backend - auth + admin-mutation + real-tool smoke
{
name: "live",
testDir: "./src/core/tests/live",
@@ -69,7 +69,7 @@ export default defineConfig({
dependencies: ["live-setup"],
},
// Enterprise license-gated SSO/SAML/audit/teams against keycloak compose
// Enterprise - license-gated SSO/SAML/audit/teams against keycloak compose
// Uses port 8080 directly (the docker compose stack publishes the
// backend's built-in frontend there); the Vite dev server is bypassed
// because the OAuth/SAML callback URLs are registered against 8080.
@@ -98,7 +98,7 @@ export default defineConfig({
webServer: {
// In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool
// pages don't pay vite's on-demand transform cost on first hit (which
// blew the 30s navigationTimeout under --workers=3 see
// blew the 30s navigationTimeout under --workers=3 - see
// all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR.
command: process.env.CI
? "npx vite preview --port 5173 --strictPort"
@@ -1467,7 +1467,7 @@ settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[adminUserSettings]
actions = "Actions"
@@ -1525,7 +1525,7 @@ comment = "Comment"
comments = "Comments"
contents = "Text"
delete = "Delete"
desc = "Use highlight, pen, text, and notes. Changes stay liveno flattening required."
desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required."
drawing = "Drawing"
duplicate = "Duplicate"
editCircle = "Edit Circle"
@@ -1851,6 +1851,13 @@ label = "Open menu for {{title}}"
[automate.files]
placeholder = "Select files to process with this automation"
[automate.folderScanWarning]
advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it."
cancel = "Cancel"
confirm = "Export anyway"
intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:"
title = "Some steps cannot run in folder scanning"
[automate.importModal]
cancel = "Cancel"
confirm = "Import"
@@ -2082,9 +2089,9 @@ placeholder = "Number of pages"
title = "Last N Pages"
[bulkSelection.operators]
and = "AND: & or \"and\" require both conditions (e.g., 1-50 & even)"
comma = "Comma: , or | combine selections (e.g., 1-10, 20)"
not = "NOT: ! or \"not\" exclude pages (e.g., 3n & not 30)"
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
text = "AND has higher precedence than comma. NOT applies within the document range."
title = "Operators"
@@ -2780,8 +2787,8 @@ title = "These PDFs look highly different"
[compare.edited]
label = "Edited PDF"
selectBaseFirst = "Select original PDF first"
placeholder = "Select the edited PDF"
selectBaseFirst = "Select original PDF first"
[compare.error]
filesMissing = "Unable to locate the selected files. Please re-select them."
@@ -2862,9 +2869,9 @@ pageLabel = "Page"
[compare.swap]
confirm = "Swap and Re-run"
label = "Swap"
confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?"
confirmTitle = "Re-run comparison?"
label = "Swap"
[compare.toasts]
unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
@@ -3184,7 +3191,7 @@ showPreferencesBtn = "Manage preferences"
title = "How we use Cookies"
[cookieBanner.popUp.description]
1 = "We use cookies and other technologies to make Stirling PDF work better for youhelping us improve our tools and keep building features you'll love."
1 = "We use cookies and other technologies to make Stirling PDF work better for you-helping us improve our tools and keep building features you'll love."
2 = "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
[cookieBanner.preferencesModal]
@@ -3197,16 +3204,16 @@ subtitle = "Cookie Usage"
title = "Consent Preferences Center"
[cookieBanner.preferencesModal.analytics]
description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assuredStirling PDF cannot and will never track the content of the documents you work with."
description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with."
title = "Analytics"
[cookieBanner.preferencesModal.description]
1 = "Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users."
2 = "Stirling PDF cannotand will nevertrack or access the content of the documents you use."
2 = "Stirling PDF cannot-and will never-track or access the content of the documents you use."
3 = "Your privacy and trust are at the core of what we do."
[cookieBanner.preferencesModal.necessary]
description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out formswhich is why they can't be turned off."
description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms-which is why they can't be turned off."
[cookieBanner.preferencesModal.necessary.title]
1 = "Strictly Necessary Cookies"
@@ -3759,13 +3766,209 @@ expand = "Expand sidebar"
files = "Files"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
leaveMyFiles = "Leave My Files"
myFiles = "My Files"
noFiles = "No files yet"
openFileManager = "Open file manager"
openFileManager = "Browse all files & folders"
openFromComputer = "Open from computer"
openSettings = "Open settings"
search = "Search"
searchPlaceholder = "Search files..."
[filesPage]
addToWorkspace = "Add to workspace"
addToWorkspaceCount = "Add {{count}} to workspace"
allFiles = "All files"
back = "Back"
backToFolder = "Back to {{folder}}"
backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
create = "Create"
cycleBlocked = "Can't move a folder into one of its own subfolders."
delete = "Delete"
deleteFolder = "Delete folder"
deleteFolderBody = "Delete folder \"{{name}}\"?"
deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected."
deleteFolderContents = "Also delete {{count}} file(s) inside the folder"
deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered."
deleteFolderError = "Could not delete the folder. Try again."
deleteFolderKeepHint = "Files inside will be moved to All files."
deleteFolderTitle = "Delete folder?"
deselectAll = "Clear selection"
details = "Details"
detailsCount = "{{count}} files selected"
dismissError = "Dismiss"
download = "Download"
downloadAll = "Download all"
downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder."
file = "File"
fileMenu = "File actions"
folder = "Folder"
folderItems = "{{count}} items"
folderMenu = "Folder actions"
inPath = "in {{path}}"
inWorkspace = "Open"
inWorkspaceAria = "Already in workspace"
loading = "Loading…"
localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it."
moreActions = "More folder actions"
moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first."
moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)."
moveTo = "Move to…"
myFiles = "My Files"
newFolder = "New folder"
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
newRootFolder = "New folder at root"
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
open = "Open"
openInWorkbench = "Open in workbench"
openVersionInWorkspace = "Open in workspace"
originFilter = "Filter by source"
quickView = "Quick view"
refresh = "Refresh from server"
remove = "Delete"
removeConfirm = "Delete {{count}} file(s)? This cannot be undone."
removeVersion = "Remove this version"
rename = "Rename"
renameFolder = "Rename folder"
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
save = "Save"
saveToServer = "Save to server"
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
selectFile = "Select file {{name}}"
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to set storage.sharing.enabled=true to turn on share links and per-user access."
shareManage = "Manage sharing"
showDetails = "Show details"
summary = "{{count}} items"
syncFailed = "Folder sync failed: {{message}}"
syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged."
tree = "Folders"
upload = "Upload"
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
versionOrigin = "Original upload"
versionsCount = "{{count}} versions"
versionShowHidden = "Show {{count}} earlier versions"
viewVersion = "View this version"
appearance.colour = "Colour"
appearance.icon = "Icon"
appearance.title = "Appearance"
appearance.useColour = "Use colour {{c}}"
column.modified = "Modified"
column.name = "Name"
column.size = "Size"
column.type = "Type"
empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files."
empty.newFolderCta = "Create folder"
empty.title = "This folder is empty"
empty.uploadCta = "Upload files"
empty.cloud.hint = "Upload a file to start, or create a folder to organise."
empty.cloud.offlineHint = "Reconnect to load your cloud library."
empty.cloud.offlineTitle = "No cached cloud files"
empty.cloud.title = "No cloud files yet"
empty.imSharing.hint = "Invite a teammate to one of your files to see it listed here."
empty.imSharing.title = "Not sharing with anyone yet"
empty.local.hint = "Files saved without uploading stay here. Drop a file to add one."
empty.local.title = "No local-only files"
empty.recent.hint = "Files you open or edit will appear here."
empty.recent.title = "Nothing modified yet"
empty.shared.hint = "When someone shares a file via link, it appears here."
empty.shared.title = "Nothing shared with you"
empty.sharedByMe.hint = "Create a share link on any of your files to surface it here."
empty.sharedByMe.title = "No share links yet"
error.actionFailed = "Could not {{action}}."
error.actionFailedDetail = "Could not {{action}}: {{message}}"
error.deleteFolderFailed = "Could not delete folder."
error.deleteFolderFailedDetail = "Could not delete folder: {{message}}"
error.folderAppearanceFailed = "Could not update folder appearance."
error.folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}"
error.moveFilesFailed = "Could not move files."
error.moveFilesFailedDetail = "Could not move files: {{message}}"
error.moveFolderFailed = "Could not move folder."
error.moveFolderFailedDetail = "Could not move folder: {{message}}"
error.removeFilesFailed = "Could not remove files."
error.removeFilesFailedDetail = "Could not remove files: {{message}}"
error.uploadFilesFailed = "Could not upload files."
error.uploadFilesFailedDetail = "Could not upload files: {{message}}"
field.added = "Added"
field.count = "Files"
field.folder = "Folder"
field.modified = "Modified"
field.name = "Name"
field.size = "Size"
field.toolHistory = "Tool history"
field.toolHistoryAtVersion = "Cumulative tool chain"
field.totalSize = "Total size"
field.type = "Type"
field.versionHistory = "Version journey"
folderName.cancel = "Cancel"
folderName.error = "Could not save folder. Try again."
folderName.label = "Folder name"
folderName.placeholder = "Folder name"
moveDialog.cancel = "Cancel"
moveDialog.confirm = "Move here"
moveDialog.error = "Could not move. Try again."
moveDialog.hint = "Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left."
moveDialog.newFolderCancel = "Discard"
moveDialog.newFolderCreate = "Create"
moveDialog.newFolderError = "Could not create folder. Try again."
moveDialog.newFolderLabel = "New folder name"
moveDialog.newFolderPlaceholder = "Folder name"
moveDialog.newFolderToggle = "Create new folder…"
moveDialog.title = "Move to folder"
origin.all = "All sources"
origin.cloud = "Cloud"
origin.cloudHint = "Stored on the Stirling server"
origin.local = "Local"
origin.localHint = "Only stored in this browser"
origin.shared = "Shared"
origin.sharedHint = "Shared with you via link"
sort.modifiedAsc = "Oldest first"
sort.modifiedDesc = "Recent first"
sort.nameAsc = "Name A→Z"
sort.nameDesc = "Name Z→A"
sort.sizeAsc = "Smallest first"
sort.sizeDesc = "Largest first"
syncError.client = "Folder sync failed."
syncError.network = "Could not reach the server."
syncError.server = "Server error during folder sync."
tabName.imSharing = "I'm sharing"
tabName.local = "Local"
tabName.recent = "Recent"
tabName.shared = "Shared with me"
tabName.sharedByMe = "Shared by me"
tabs.all = "All"
tabs.ariaLabel = "File views"
tabs.cloud = "Cloud"
tabs.imSharing = "I'm sharing"
tabs.local = "Local"
tabs.recent = "Recent"
tabs.shared = "Shared with me"
tabs.sharedByMe = "Shared by me"
treeMenu.actions = "Folder actions for {{name}}"
treeMenu.collapse = "Collapse folder"
treeMenu.delete = "Delete folder"
treeMenu.expand = "Expand folder"
treeMenu.newSubfolder = "New subfolder"
treeMenu.rename = "Rename"
typeFilter.allTypes = "All types"
typeFilter.label = "Filter by type"
viewMode.grid = "Grid view"
viewMode.label = "View mode"
viewMode.list = "List view"
[fileToPDF]
credit = "This service uses LibreOffice and Unoconv for file conversion."
header = "Convert any file to PDF"
@@ -4904,7 +5107,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
viewSwitcher = "Use these controls to select how you want to view your PDFs."
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
wrapUp = "You're all set! You can replay this tour anytime just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
wrapUp = "You're all set! You can replay this tour anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[onboarding.buttons]
back = "Back"
@@ -5244,9 +5447,9 @@ description = "Use n in formulas for patterns."
title = "Mathematical Functions"
[pageSelection.tooltip.operators]
and = "AND: & or \"and\" require both conditions (e.g., 1-50 & even)"
comma = "Comma: , or | combine selections (e.g., 1-10, 20)"
not = "NOT: ! or \"not\" exclude pages (e.g., 3n & not 30)"
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
text = "AND has higher precedence than comma. NOT applies within the document range."
title = "Operators"
@@ -5434,7 +5637,7 @@ modified = "Edited"
unsaved = "Edited"
[pdfTextEditor.disclaimer]
alpha = "This alpha viewer is still evolvingcertain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
heading = "Preview Limitations"
previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible."
textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here."
@@ -5512,7 +5715,7 @@ paragraph = "Paragraph page"
sparse = "Sparse text"
[pdfTextEditor.tooltip.alpha]
text = "This alpha viewer is still evolvingcertain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
title = "Alpha Viewer"
[pdfTextEditor.tooltip.header]
@@ -6569,51 +6772,6 @@ title = "High Contrast"
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
title = "Invert All Colours"
[workbenchBar]
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
closeAll = "Close All Files"
closePdf = "Close PDF"
closeSelected = "Close Selected Files"
deleteSelected = "Delete Selected Pages"
deselectAll = "Deselect All"
downloadAll = "Download All"
downloadSelected = "Download Selected Files"
draw = "Draw"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
language = "Language"
panMode = "Pan Mode"
print = "Print PDF"
readAloud = "Read Aloud"
readAloudLanguage = "Language"
readAloudSpeed = "Speed"
redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
ruler = "Ruler / Measure"
save = "Save"
saveAll = "Save All"
saveAs = "Save As"
saveChanges = "Save Changes"
search = "Search PDF"
selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
toggleComments = "Comments"
toggleLayers = "Toggle Layers"
toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
activeFiles = "Active Files"
multiTool = "Multi-Tool"
viewer = "Viewer"
[rotate]
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
@@ -6752,7 +6910,7 @@ useCase2 = "Split flatbed batches into separate files"
useCase3 = "Break collages into individual photos"
useCase4 = "Pull photos from documents"
whatThisDoes = "What this does"
whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite imageno manual cropping."
whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping."
whenToUse = "When to use"
[search]
@@ -6907,6 +7065,20 @@ auto = "Auto"
fitPage = "Fit page"
fitWidth = "Fit width"
[settings.help]
label = "Tours"
title = "Help"
[settings.help.adminTour]
description = "Explore team management, system settings, and enterprise features."
start = "Start"
title = "Admin Tour"
[settings.help.toolsTour]
description = "Walk through uploading files, picking a tool, and reviewing results."
start = "Start"
title = "Tools Tour"
[settings.hotkeys]
capturing = "Press keys… (Esc to cancel)"
change = "Change shortcut"
@@ -7038,20 +7210,6 @@ title = "Team"
enableLoginFirst = "Enable login mode first"
requiresEnterprise = "Requires Enterprise license"
[settings.help]
label = "Tours"
title = "Help"
[settings.help.adminTour]
description = "Explore team management, system settings, and enterprise features."
start = "Start"
title = "Admin Tour"
[settings.help.toolsTour]
description = "Walk through uploading files, picking a tool, and reviewing results."
start = "Start"
title = "Tools Tour"
[settings.workspace]
people = "People"
teams = "Teams"
@@ -8249,8 +8407,8 @@ csvStats = "{{rows}} rows · {{columns}} columns · {{size}}"
emptyFile = "Empty file"
fileTypeBadge = "{{type}} File"
htmlPreview = "HTML preview"
htmlPreviewWarning = "HTML preview external resources may not load · {{size}}"
invalidJson = "Invalid JSON showing raw content"
htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}"
invalidJson = "Invalid JSON - showing raw content"
lineNumbers = "Line numbers"
loading = "Loading..."
renderMarkdown = "Render markdown"
@@ -8460,6 +8618,50 @@ bullet3 = "Image will be resized to fit signature area"
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
title = "Upload Signature Image"
[workbenchBar]
activeFiles = "Active Files"
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
closeAll = "Close All Files"
closePdf = "Close PDF"
closeSelected = "Close Selected Files"
deleteSelected = "Delete Selected Pages"
deselectAll = "Deselect All"
downloadAll = "Download All"
downloadSelected = "Download Selected Files"
draw = "Draw"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
language = "Language"
multiTool = "Multi-Tool"
panMode = "Pan Mode"
print = "Print PDF"
readAloud = "Read Aloud"
readAloudLanguage = "Language"
readAloudSpeed = "Speed"
redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
ruler = "Ruler / Measure"
save = "Save"
saveAll = "Save All"
saveAs = "Save As"
saveChanges = "Save Changes"
search = "Search PDF"
selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
toggleComments = "Comments"
toggleLayers = "Toggle Layers"
toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
viewer = "Viewer"
[workspace]
title = "Workspace"
@@ -32,6 +32,7 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
import { FolderProvider } from "@app/contexts/FolderContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -125,39 +126,41 @@ export function AppProviders({
enableUrlSync={true}
enablePersistence={true}
>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
<FolderProvider>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FolderProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
@@ -6,7 +6,6 @@ import type { FileId } from "@app/types/file";
import { useFileManager } from "@app/hooks/useFileManager";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { Tool } from "@app/types/tool";
import MobileLayout from "@app/components/fileManager/MobileLayout";
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
import DragOverlay from "@app/components/fileManager/DragOverlay";
@@ -20,8 +19,14 @@ import { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
/**
* Structural prop: anything that exposes an optional `supportedFormats`
* string array. Both `Tool` (from `@app/types/tool`) and `ToolRegistryEntry`
* (from `@app/data/toolsTaxonomy`) satisfy this, so callers can pass either
* without an `as any` cast.
*/
interface FileManagerProps {
selectedTool?: Tool | null;
selectedTool?: { supportedFormats?: string[] } | null;
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
@@ -0,0 +1,130 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Button,
Checkbox,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { FolderRecord } from "@app/types/folder";
interface DeleteFolderDialogProps {
opened: boolean;
folder: FolderRecord | null;
/** Number of files inside the folder (and subtree). */
fileCount: number;
onClose: () => void;
/** Confirm; `deleteContents` is true when the user opted in to delete files. */
onConfirm: (deleteContents: boolean) => void | Promise<void>;
}
export function DeleteFolderDialog({
opened,
folder,
fileCount,
onClose,
onConfirm,
}: DeleteFolderDialogProps) {
const { t } = useTranslation();
const [deleteContents, setDeleteContents] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setDeleteContents(false);
setSubmitting(false);
setError(null);
}
}, [opened]);
if (!folder) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("filesPage.deleteFolderTitle", "Delete folder?")}
centered
size="md"
>
<Stack gap="md">
<Text size="sm">
{t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', {
name: folder.name,
})}
</Text>
{fileCount > 0 && (
<Checkbox
checked={deleteContents}
onChange={(e) => setDeleteContents(e.currentTarget.checked)}
disabled={submitting}
label={t(
"filesPage.deleteFolderContents",
"Also delete {{count}} file(s) inside the folder",
{ count: fileCount },
)}
/>
)}
{fileCount > 0 && (
<Text size="xs" c="dimmed">
{deleteContents
? t(
"filesPage.deleteFolderContentsWarning",
"Files will be permanently removed and cannot be recovered.",
)
: t(
"filesPage.deleteFolderKeepHint",
"Files inside will be moved to All files.",
)}
</Text>
)}
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
{t("filesPage.cancel", "Cancel")}
</Button>
<Button
color="red"
loading={submitting}
onClick={async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm(deleteContents);
onClose();
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.deleteFolderError",
"Could not delete the folder. Try again.",
),
);
} finally {
setSubmitting(false);
}
}}
>
{t("filesPage.delete", "Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,658 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Badge, Button, Menu, Tooltip } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import VisibilityIcon from "@mui/icons-material/Visibility";
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import HistoryIcon from "@mui/icons-material/History";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import LinkIcon from "@mui/icons-material/Link";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { FileId, ToolOperation } from "@app/types/file";
import { ToolId } from "@app/types/toolId";
import { FolderRecord } from "@app/types/folder";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import {
downloadFileFromStorage,
downloadMultipleFiles,
} from "@app/utils/downloadUtils";
import ToolChain from "@app/components/shared/ToolChain";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { fileStorage } from "@app/services/fileStorage";
interface FileDetailsPanelProps {
selectedFileIds: FileId[];
fileMap: Map<FileId, StirlingFileStub>;
currentFolder: FolderRecord | null;
onClose: () => void;
onAddToWorkspace: (fileIds: FileId[]) => void;
onQuickView: (fileId: FileId) => void;
onMove: (fileIds: FileId[]) => void;
onRemove: (fileIds: FileId[]) => void;
/** Save to server; only shown when at least one selected file is local-only. */
onSaveToServer?: (files: StirlingFileStub[]) => void;
}
export function FileDetailsPanel({
selectedFileIds,
fileMap,
currentFolder,
onClose,
onAddToWorkspace,
onQuickView,
onMove,
onRemove,
onSaveToServer,
}: FileDetailsPanelProps) {
const { t } = useTranslation();
const { sharingEnabled } = useSharingEnabled();
const files = useMemo(
() =>
selectedFileIds
.map((id) => fileMap.get(id))
.filter((f): f is StirlingFileStub => Boolean(f)),
[selectedFileIds, fileMap],
);
// Hooks must run before any early return.
const [downloading, setDownloading] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
// Version chain for the selected file; empty for v1 or multi-select.
const [versionChain, setVersionChain] = useState<StirlingFileStub[]>([]);
const singleFileForChain = files.length === 1 ? files[0] : null;
useEffect(() => {
if (!singleFileForChain) {
setVersionChain([]);
return;
}
let cancelled = false;
const rootId = (singleFileForChain.originalFileId ??
singleFileForChain.id) as FileId;
fileStorage
.getHistoryChainStubs(rootId)
.then((chain) => {
if (!cancelled) setVersionChain(chain);
})
.catch((err) => {
console.error("Failed to load version history", err);
if (!cancelled) setVersionChain([]);
});
return () => {
cancelled = true;
};
}, [singleFileForChain]);
if (files.length === 0) {
return null;
}
const single = files.length === 1 ? files[0]! : null;
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : "";
// Files still needing a server upload; drives Save-to-server visibility.
const localOnlyFiles = files.filter((f) => f.remoteStorageId == null);
const handleDownload = async () => {
setDownloading(true);
try {
if (single) {
await downloadFileFromStorage(single);
} else {
await downloadMultipleFiles(files);
}
} catch (err) {
console.error("Download failed", err);
} finally {
setDownloading(false);
}
};
return (
<aside
className="files-page-details"
aria-label={t("filesPage.details", "Details")}
>
<div className="files-page-details-header">
<strong>
{single
? t("filesPage.details", "Details")
: t("filesPage.detailsCount", "{{count}} files selected", {
count: files.length,
})}
</strong>
<Tooltip
label={t("filesPage.closeDetails", "Close details")}
withinPortal
>
<ActionIcon variant="subtle" size="sm" onClick={onClose}>
<CloseIcon fontSize="small" />
</ActionIcon>
</Tooltip>
</div>
<div className="files-page-details-body">
{single ? (
<>
<div className="files-page-details-thumb">
{single.thumbnailUrl ? (
<img src={single.thumbnailUrl} alt="" />
) : (
<PictureAsPdfIcon
style={{ fontSize: "3rem", color: "var(--text-muted)" }}
/>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexWrap: "wrap",
}}
>
<h3 style={{ margin: 0, wordBreak: "break-word", flex: 1 }}>
{single.name}
</h3>
{ext && (
// Custom span; Mantine Badge default rendered invisible in dark mode.
<span className="files-page-details-ext-tag">{ext}</span>
)}
{(single.versionNumber ?? 1) > 1 && (
<Badge size="sm" variant="filled" color="blue">
v{single.versionNumber}
</Badge>
)}
</div>
<div className="files-page-details-fieldlist">
<DetailField
label={t("filesPage.field.size", "Size")}
value={formatFileSize(single.size)}
/>
<DetailField
label={t("filesPage.field.type", "Type")}
value={single.type || "-"}
/>
<DetailField
label={t("filesPage.field.modified", "Modified")}
value={getFileDate({ lastModified: single.lastModified })}
/>
<DetailField
label={t("filesPage.field.added", "Added")}
value={
single.createdAt
? getFileDate({ lastModified: single.createdAt })
: "-"
}
/>
<DetailField
label={t("filesPage.field.folder", "Folder")}
value={
currentFolder
? currentFolder.name
: t("filesPage.allFiles", "All files")
}
/>
</div>
{single.toolHistory && single.toolHistory.length > 0 && (
<div className="files-page-details-tool-history">
<div className="files-page-details-tool-history-label">
{t("filesPage.field.toolHistory", "Tool history")}
</div>
<ToolChain
toolChain={single.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
)}
{/* Version journey. Each tool run writes a new StirlingFile
with the same `originalFileId` and an incremented
`versionNumber`, so the chain reconstructs the edit
timeline. The previous file manager exposed this and the
refactored one had silently dropped it; this revival also
shows WHICH tool was added at each step (the delta from
the prior version) so the user can read the journey
top-to-bottom. Long chains (> 6) collapse the middle. */}
{versionChain.length > 1 && (
<VersionTimeline
chain={versionChain}
currentId={single.id}
onQuickView={onQuickView}
onAddToWorkspace={onAddToWorkspace}
onRemove={onRemove}
/>
)}
</>
) : (
<div className="files-page-details-fieldlist">
<DetailField
label={t("filesPage.field.totalSize", "Total size")}
value={formatFileSize(totalSize)}
/>
<DetailField
label={t("filesPage.field.count", "Files")}
value={String(files.length)}
/>
</div>
)}
<div
style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}
>
<Button
leftSection={<OpenInNewIcon fontSize="small" />}
variant="filled"
onClick={() => onAddToWorkspace(selectedFileIds)}
>
{files.length === 1
? t("filesPage.addToWorkspace", "Add to workspace")
: t(
"filesPage.addToWorkspaceCount",
"Add {{count}} to workspace",
{ count: files.length },
)}
</Button>
{single && (
<Button
leftSection={<VisibilityIcon fontSize="small" />}
variant="subtle"
onClick={() => onQuickView(single.id)}
>
{t("filesPage.quickView", "Quick view")}
</Button>
)}
<Button
leftSection={<DownloadIcon fontSize="small" />}
variant="default"
onClick={handleDownload}
loading={downloading}
>
{single
? t("filesPage.download", "Download")
: t("filesPage.downloadAll", "Download all")}
</Button>
{/* Share is single-file only. When sharing is disabled in
server config (storage.sharing.enabled=false) we still
render the button - disabled with an explanatory tooltip -
so users discover the feature exists and know how to
enable it, rather than wondering why "share" is missing
from the action stack on their build. */}
{single && (
<Tooltip
label={t(
"filesPage.shareDisabledHint",
"File sharing isn't enabled on this server. Ask your admin to set storage.sharing.enabled=true to turn on share links and per-user access.",
)}
disabled={sharingEnabled}
withinPortal
multiline
w={260}
>
<Button
leftSection={<LinkIcon fontSize="small" />}
variant="default"
disabled={!sharingEnabled}
onClick={() => setShareModalOpen(true)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: sharingEnabled ? undefined : "auto",
},
}}
>
{t("filesPage.shareManage", "Manage sharing")}
</Button>
</Tooltip>
)}
<Button
leftSection={<DriveFileMoveIcon fontSize="small" />}
variant="default"
onClick={() => onMove(selectedFileIds)}
>
{t("filesPage.moveTo", "Move to…")}
</Button>
{/* Save to server; shown when any selected file is local-only. */}
{onSaveToServer && localOnlyFiles.length > 0 && (
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
onClick={() => onSaveToServer(localOnlyFiles)}
>
{t("filesPage.saveToServer", "Save to server")}
</Button>
)}
<Button
leftSection={<DeleteIcon fontSize="small" />}
color="red"
variant="light"
onClick={() => onRemove(selectedFileIds)}
>
{t("filesPage.remove", "Delete")}
</Button>
</div>
</div>
{/* Single panel-level mount; gated on sharingEnabled. */}
{single && sharingEnabled && (
<ShareManagementModal
opened={shareModalOpen}
onClose={() => setShareModalOpen(false)}
file={single}
/>
)}
</aside>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div className="files-page-details-field">
<span className="files-page-details-field-label">{label}</span>
<span className="files-page-details-field-value">{value}</span>
</div>
);
}
/** Tool that produced `version` from `prior`; null for v1. */
function deltaToolFor(
version: StirlingFileStub,
prior: StirlingFileStub | null,
): ToolOperation | null {
if (!prior) return null;
const priorLen = prior.toolHistory?.length ?? 0;
const curr = version.toolHistory ?? [];
return curr[priorLen] ?? null;
}
interface VersionTimelineProps {
/** Chain sorted oldest-first. */
chain: StirlingFileStub[];
/** Currently selected version. */
currentId: FileId;
onQuickView: (fileId: FileId) => void;
onAddToWorkspace: (fileIds: FileId[]) => void;
onRemove: (fileIds: FileId[]) => void;
}
/** Version timeline with per-row tool deltas and collapse-when-long. */
function VersionTimeline({
chain,
currentId,
onQuickView,
onAddToWorkspace,
onRemove,
}: VersionTimelineProps) {
const { t } = useTranslation();
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
// Newest-first ordering.
const ordered = useMemo(
() =>
[...chain].sort(
(a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1),
),
[chain],
);
// Index by versionNumber for prior-version lookup.
const byVersionNumber = useMemo(() => {
const map = new Map<number, StirlingFileStub>();
for (const v of chain) {
map.set(v.versionNumber ?? 1, v);
}
return map;
}, [chain]);
// Collapse middle when long: 3 newest + ellipsis + 2 oldest.
const COLLAPSE_THRESHOLD = 6;
const collapsible = ordered.length > COLLAPSE_THRESHOLD;
type Row =
| { kind: "version"; version: StirlingFileStub }
| {
kind: "ellipsis";
hidden: number;
};
const rows: Row[] = useMemo(() => {
if (!collapsible || showAllCollapsed) {
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
}
const head = ordered
.slice(0, 3)
.map((v) => ({ kind: "version", version: v }) as Row);
const tail = ordered
.slice(-2)
.map((v) => ({ kind: "version", version: v }) as Row);
const hidden = ordered.length - 5;
return [...head, { kind: "ellipsis", hidden }, ...tail];
}, [collapsible, showAllCollapsed, ordered]);
const toggleExpand = (id: FileId) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="files-page-details-version-timeline">
<div className="files-page-details-version-timeline-label">
<HistoryIcon fontSize="small" />
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
<span className="files-page-details-version-timeline-count">
{t("filesPage.versionsCount", "{{count}} versions", {
count: ordered.length,
})}
</span>
</div>
<ol className="files-page-details-version-timeline-list">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.kind === "ellipsis") {
return (
<li
key="ellipsis"
className="files-page-details-version-timeline-ellipsis"
>
<div className="files-page-details-version-timeline-rail">
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<button
type="button"
className="files-page-details-version-timeline-ellipsis-btn"
onClick={() => setShowAllCollapsed(true)}
>
{t(
"filesPage.versionShowHidden",
"Show {{count}} earlier versions",
{ count: row.hidden },
)}
</button>
</li>
);
}
const v = row.version;
const isActive = v.id === currentId;
const isExpanded = expandedIds.has(v.id);
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
const delta = deltaToolFor(v, prior);
return (
<li
key={v.id}
className={`files-page-details-version-timeline-row${
isActive ? " is-active" : ""
}`}
>
<div className="files-page-details-version-timeline-rail">
<span
className={`files-page-details-version-timeline-rail-dot${
isActive ? " is-active" : ""
}`}
/>
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<div className="files-page-details-version-timeline-body">
<button
type="button"
className="files-page-details-version-timeline-summary"
onClick={() => toggleExpand(v.id)}
aria-expanded={isExpanded}
>
<Badge
size="xs"
variant={isActive ? "filled" : "outline"}
color="blue"
>
v{v.versionNumber ?? 1}
</Badge>
{delta ? (
<span className="files-page-details-version-timeline-delta">
<span className="files-page-details-version-timeline-delta-plus">
+
</span>
<ToolLabel toolId={delta.toolId} />
</span>
) : (
<span className="files-page-details-version-timeline-delta is-origin">
{t("filesPage.versionOrigin", "Original upload")}
</span>
)}
<span className="files-page-details-version-timeline-spacer" />
<KeyboardArrowDownIcon
className={`files-page-details-version-timeline-chevron${
isExpanded ? " is-expanded" : ""
}`}
fontSize="small"
/>
</button>
<div className="files-page-details-version-timeline-meta-line">
<span>{formatFileSize(v.size)}</span>
{v.lastModified ? (
<>
<span>·</span>
<span>
{getFileDate({ lastModified: v.lastModified })}
</span>
</>
) : null}
{!isActive && (
<>
<span className="files-page-details-version-timeline-spacer" />
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
aria-label={t(
"filesPage.versionActions",
"Version actions",
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<VisibilityIcon fontSize="small" />}
onClick={() => onQuickView(v.id)}
>
{t("filesPage.viewVersion", "View this version")}
</Menu.Item>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={() => onAddToWorkspace([v.id])}
>
{t(
"filesPage.openVersionInWorkspace",
"Open in workspace",
)}
</Menu.Item>
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => {
void downloadFileFromStorage(v);
}}
>
{t(
"filesPage.downloadVersion",
"Download this version",
)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => onRemove([v.id])}
>
{t(
"filesPage.removeVersion",
"Remove this version",
)}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
)}
</div>
{isExpanded && (
// Filename + full cumulative tool chain.
<div className="files-page-details-version-timeline-expanded">
<DetailField
label={t("filesPage.field.name", "Name")}
value={v.name}
/>
{v.toolHistory && v.toolHistory.length > 0 && (
<div className="files-page-details-version-timeline-toolchain">
<span className="files-page-details-version-timeline-toolchain-label">
{t(
"filesPage.field.toolHistoryAtVersion",
"Cumulative tool chain",
)}
</span>
<ToolChain
toolChain={v.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
)}
</div>
)}
</div>
</li>
);
})}
</ol>
{collapsible && showAllCollapsed && (
<button
type="button"
className="files-page-details-version-timeline-collapse-btn"
onClick={() => setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
</button>
)}
</div>
);
}
/** Translated tool name via `home.{toolId}.title`. */
function ToolLabel({ toolId }: { toolId: ToolId }) {
const { t } = useTranslation();
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import ComputerIcon from "@mui/icons-material/Computer";
import CloudDoneIcon from "@mui/icons-material/CloudDone";
import GroupIcon from "@mui/icons-material/Group";
import { FileOrigin } from "@app/components/filesPage/fileOrigin";
interface FileOriginBadgeProps {
origin: FileOrigin;
/** Compact (icon-only) vs full (icon + text). */
compact?: boolean;
}
const styles = {
base: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.1rem 0.4rem",
borderRadius: "999px",
fontSize: "0.68rem",
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
lineHeight: 1.2,
},
local: {
background:
"color-mix(in srgb, var(--text-muted, #6b7280) 16%, transparent)",
color: "var(--text-secondary)",
},
cloud: {
background:
"color-mix(in srgb, var(--accent-interactive, #6366f1) 16%, transparent)",
color: "var(--accent-interactive, #6366f1)",
},
shared: {
background:
"color-mix(in srgb, var(--mantine-color-orange-6, #f97316) 16%, transparent)",
color: "var(--mantine-color-orange-6, #f97316)",
},
};
export function FileOriginBadge({
origin,
compact = false,
}: FileOriginBadgeProps) {
const { t } = useTranslation();
const config = (() => {
switch (origin) {
case "cloud":
return {
label: t("filesPage.origin.cloud", "Cloud"),
icon: <CloudDoneIcon style={{ fontSize: "0.85rem" }} />,
style: styles.cloud,
tooltip: t(
"filesPage.origin.cloudHint",
"Stored on the Stirling server",
),
};
case "shared-with-me":
return {
label: t("filesPage.origin.shared", "Shared"),
icon: <GroupIcon style={{ fontSize: "0.85rem" }} />,
style: styles.shared,
tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"),
};
case "local":
default:
return {
label: t("filesPage.origin.local", "Local"),
icon: <ComputerIcon style={{ fontSize: "0.85rem" }} />,
style: styles.local,
tooltip: t(
"filesPage.origin.localHint",
"Only stored in this browser",
),
};
}
})();
const badge = (
<span style={{ ...styles.base, ...config.style }}>
{config.icon}
{!compact && config.label}
</span>
);
return (
<Tooltip label={config.tooltip} withinPortal>
{badge}
</Tooltip>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
/**
* Inline colour + icon picker for a folder. Rendered inside the folder
* kebab menu (Mantine Menu.Item with a custom body) so the menu can
* still own close-on-outside-click behaviour.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import { FolderRecord, FOLDER_COLOR_PALETTE } from "@app/types/folder";
import {
FOLDER_ICONS,
FolderIconOption,
} from "@app/components/filesPage/folderIcons";
interface FolderAppearancePickerProps {
folder: FolderRecord;
onChange: (next: { color?: string; icon?: string | null }) => void;
/** When true, all colour + icon buttons are unresponsive (e.g. while offline). */
disabled?: boolean;
}
export function FolderAppearancePicker({
folder,
onChange,
disabled = false,
}: FolderAppearancePickerProps) {
const { t } = useTranslation();
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
minWidth: "16rem",
opacity: disabled ? 0.55 : 1,
pointerEvents: disabled ? "none" : undefined,
}}
aria-disabled={disabled || undefined}
>
<Section label={t("filesPage.appearance.colour", "Colour")}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(8, 1fr)",
gap: "0.35rem",
}}
>
{FOLDER_COLOR_PALETTE.map((c) => (
<button
key={c}
type="button"
disabled={disabled}
aria-label={t(
"filesPage.appearance.useColour",
"Use colour {{c}}",
{ c },
)}
onClick={(e) => {
e.stopPropagation();
onChange({ color: c });
}}
style={{
width: "1.6rem",
height: "1.6rem",
borderRadius: "50%",
border:
folder.color === c
? "2px solid var(--text-primary)"
: "2px solid transparent",
background: c,
cursor: disabled ? "not-allowed" : "pointer",
padding: 0,
outlineOffset: "2px",
}}
/>
))}
</div>
</Section>
<Section label={t("filesPage.appearance.icon", "Icon")}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gap: "0.25rem",
}}
>
{FOLDER_ICONS.map((icon) => (
<IconButton
key={icon.id}
icon={icon}
disabled={disabled}
selected={
(icon.id === "none" && !folder.icon) || folder.icon === icon.id
}
onClick={() =>
onChange({ icon: icon.id === "none" ? null : icon.id })
}
/>
))}
</div>
</Section>
</div>
);
}
function Section({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
<span
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted)",
fontWeight: 600,
}}
>
{label}
</span>
{children}
</div>
);
}
function IconButton({
icon,
selected,
onClick,
disabled = false,
}: {
icon: FolderIconOption;
selected: boolean;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Tooltip label={icon.label} withinPortal>
<button
type="button"
disabled={disabled}
aria-label={icon.label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
width: "2rem",
height: "2rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.1rem",
borderRadius: "0.4rem",
background: selected ? "var(--hover-bg)" : "transparent",
border: selected
? "1px solid var(--accent-interactive, #6366f1)"
: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
padding: 0,
color: "var(--text-primary)",
}}
>
{icon.glyph || "-"}
</button>
</Tooltip>
);
}
@@ -0,0 +1,111 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
interface FolderNameDialogProps {
opened: boolean;
title: string;
initialName?: string;
submitLabel: string;
onClose: () => void;
onSubmit: (name: string) => void | Promise<void>;
}
export function FolderNameDialog({
opened,
title,
initialName = "",
submitLabel,
onClose,
onSubmit,
}: FolderNameDialogProps) {
const { t } = useTranslation();
const [value, setValue] = useState(initialName);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setValue(initialName);
setSubmitting(false);
setError(null);
}
}, [opened, initialName]);
const submit = async () => {
const name = value.trim();
if (!name) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(name);
onClose();
} catch (err) {
// Keep dialog open so the user can retry. Closing on error was a
// silent failure (the dialog vanished, but the folder was never
// created - user thinks success, sees no folder).
setError(
err instanceof Error
? err.message
: t(
"filesPage.folderName.error",
"Could not save folder. Try again.",
),
);
} finally {
setSubmitting(false);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
centered
size="sm"
keepMounted
transitionProps={{ duration: 0 }}
>
<Stack gap="sm">
<TextInput
autoFocus
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={t("filesPage.folderName.placeholder", "Folder name")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
maxLength={120}
aria-label={t("filesPage.folderName.label", "Folder name")}
/>
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t("filesPage.folderName.cancel", "Cancel")}
</Button>
<Button
onClick={submit}
loading={submitting}
disabled={!value.trim()}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,170 @@
/**
* Stylised folder thumbnail.
*
* A custom SVG folder shape that takes its accent colour from
* `FolderRecord.color` and shows the contained-file count as a small badge
* in the corner. Renders proportionally inside whatever container it's
* placed in (file card thumb, list-row icon).
*/
import React, { useId } from "react";
interface FolderThumbnailProps {
color?: string;
fileCount?: number;
/** Visual scale - "thumb" for cards, "row" for list rows, "tree" for nav. */
size?: "thumb" | "row" | "tree";
/** Optional glyph (emoji) overlaid in the centre of the front pocket. */
iconGlyph?: string;
}
const SIZE_PX: Record<NonNullable<FolderThumbnailProps["size"]>, number> = {
thumb: 96,
row: 22,
tree: 18,
};
export function FolderThumbnail({
color,
fileCount,
size = "thumb",
iconGlyph,
}: FolderThumbnailProps) {
const accent = color ?? "var(--accent-interactive, #6366f1)";
const px = SIZE_PX[size];
const showBadge = size === "thumb" && (fileCount ?? 0) > 0;
// Per-instance unique ids - `${color}` previously embedded `#` and CSS
// function syntax in the id, which broke `url(#...)` references (Safari
// would parse the inner `#` as a new fragment start and the lookup
// would miss entirely, leaving the folder shape unfilled).
const reactId = useId();
const backId = `${reactId}-back`;
const frontId = `${reactId}-front`;
return (
<div
style={{
position: "relative",
width: px,
height: Math.round(px * 0.8),
display: "inline-block",
}}
aria-hidden="true"
>
<svg
viewBox="0 0 100 80"
preserveAspectRatio="xMidYMid meet"
style={{ display: "block", width: "100%", height: "100%" }}
>
<defs>
<linearGradient id={backId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={accent} stopOpacity="0.95" />
<stop offset="100%" stopColor={accent} stopOpacity="0.75" />
</linearGradient>
<linearGradient id={frontId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={accent} stopOpacity="0.85" />
<stop offset="100%" stopColor={accent} stopOpacity="1" />
</linearGradient>
</defs>
{/* Back panel - symmetric viewBox so the folder is visually
centred (6px breathing room on every side). */}
<path
d="M4 12 Q4 6 10 6 H38 L46 14 H90 Q96 14 96 20 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
fill={`url(#${backId})`}
/>
{/* Paper peeking out (lighter) */}
<rect
x="14"
y="22"
width="72"
height="36"
rx="4"
fill="rgba(255, 255, 255, 0.85)"
/>
<rect
x="18"
y="18"
width="64"
height="32"
rx="4"
fill="rgba(255, 255, 255, 0.55)"
/>
{/* Front pocket */}
<path
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V68 Q96 74 90 74 H10 Q4 74 4 68 Z"
fill={`url(#${frontId})`}
/>
{/* Subtle highlight on the lip */}
<path
d="M4 30 Q4 24 10 24 H90 Q96 24 96 30 V32 H4 Z"
fill="rgba(255, 255, 255, 0.18)"
/>
</svg>
{iconGlyph && size === "thumb" && (
<span
style={{
position: "absolute",
inset: "28% 0 0 0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: `${px * 0.28}px`,
lineHeight: 1,
pointerEvents: "none",
filter: "drop-shadow(0 1px 1px rgba(0,0,0,0.25))",
}}
aria-hidden="true"
>
{iconGlyph}
</span>
)}
{iconGlyph && size === "row" && (
<span
style={{
position: "absolute",
inset: "30% 0 0 0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: `${px * 0.45}px`,
lineHeight: 1,
pointerEvents: "none",
}}
aria-hidden="true"
>
{iconGlyph}
</span>
)}
{showBadge && (
<span
style={{
position: "absolute",
top: "-0.35rem",
right: "-0.35rem",
minWidth: "1.4rem",
height: "1.4rem",
borderRadius: "999px",
background: "var(--bg-surface, #fff)",
border: `1px solid ${accent}`,
color: accent,
fontSize: "0.7rem",
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0 0.35rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.12)",
lineHeight: 1,
}}
>
{fileCount}
</span>
)}
</div>
);
}
@@ -0,0 +1,198 @@
/* Secondary navigator panel that slides out from the main FileSidebar
when the user enters the My Files workbench.
Pattern:
- Outer panel is a flex item whose `width` transitions. The inner
content keeps a fixed natural width and the outer `overflow: hidden`
clips it during the slide so the user sees the panel grow from the
edge rather than the content shrinking.
- Inner content separately fades + nudges in for an attentive feel.
Styled to mirror the main FileSidebar - same toolbar background, same
section-header treatment, same icon weight - so the two read as one
unified surface. */
.folder-tree-panel {
width: 0;
flex-shrink: 0;
background: var(--bg-toolbar);
border-right: 0 solid var(--border-subtle);
overflow: hidden;
height: 100%;
pointer-events: none;
position: relative;
}
.folder-tree-panel[data-active="true"] {
width: var(--folder-tree-panel-width, 16rem);
border-right-width: 1px;
pointer-events: auto;
}
.folder-tree-panel-inner {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow-y: auto;
opacity: 0;
transform: translateX(-1rem);
/* Only fade/slide; width is driven by the inline custom property so
* dragging the resizer doesn't animate. */
transition:
opacity 0.18s ease,
transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.folder-tree-panel[data-active="true"] .folder-tree-panel-inner {
opacity: 1;
transform: translateX(0);
transition-delay: 0.04s;
}
/* Drag handle on the right edge. */
.folder-tree-panel-resizer {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 2;
background: transparent;
transition: background-color 0.15s ease;
}
.folder-tree-panel-resizer:hover,
.folder-tree-panel-resizer:focus-visible {
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 35%,
transparent
);
outline: none;
}
/* Section header - matches .file-sidebar-section-header in FileSidebar.css */
.folder-tree-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px 6px 18px;
margin-top: 4px;
flex-shrink: 0;
}
.folder-tree-panel-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
text-transform: uppercase;
}
/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the
slide-out folder navigator reads as a continuation of the main sidebar's
design language. Same row height, padding, font weight, muted icon
treatment. The hover/active state uses the same --hover-bg pill that
the sidebar's other rows do, with no heavy accent bar. */
.files-page-tree-list {
display: flex;
flex-direction: column;
padding: 4px 0 12px;
}
.files-page-tree-node {
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
border-radius: 4px;
margin: 0 8px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-size: 14px;
position: relative;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.files-page-tree-node:hover {
background: var(--hover-bg);
}
.files-page-tree-node.is-active {
background: var(--hover-bg);
color: var(--text-primary);
font-weight: 500;
}
.files-page-tree-node.is-drop-target {
background: color-mix(
in srgb,
var(--accent-interactive, #6366f1) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1);
color: var(--text-primary);
}
.files-page-tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--text-muted);
flex-shrink: 0;
}
.files-page-tree-toggle svg {
font-size: 16px !important;
}
.files-page-tree-spacer {
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.files-page-tree-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
margin-left: 8px;
font-size: 18px;
}
.files-page-tree-icon svg {
font-size: 18px !important;
}
.files-page-tree-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: 12px;
}
.files-page-tree-count {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
margin-left: 8px;
}
@media (max-width: 900px) {
/* Cap the user width on narrow viewports so the tree can't squeeze
* out the file grid. The custom property is still honoured but capped. */
.folder-tree-panel[data-active="true"] {
width: min(var(--folder-tree-panel-width, 14rem), 14rem);
}
}
@@ -0,0 +1,169 @@
/** Folder tree navigator panel rendered next to FileSidebar on /files. */
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
import { useFilesPage } from "@app/contexts/FilesPageContext";
import { useFolders } from "@app/contexts/FolderContext";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord } from "@app/types/folder";
import {
MIN_WIDTH,
MAX_WIDTH,
clamp,
computeAutoFitWidth,
loadPersistedWidth,
savePersistedWidth,
} from "@app/components/filesPage/folderTreeWidth";
import "@app/components/filesPage/FolderTreePanel.css";
interface FolderTreePanelProps {
active: boolean;
}
export function FolderTreePanel({ active }: FolderTreePanelProps) {
const { t } = useTranslation();
const {
fileCountsByFolder,
openNewFolderDialog,
openRenameFolderDialog,
promptDeleteFolder,
moveFilesTo,
} = useFilesPage();
const folders = useFolders();
const rootLabel = t("filesPage.allFiles", "All files");
const [width, setWidth] = useState<number>(() => {
const persisted = loadPersistedWidth();
return persisted ?? 256;
});
const userSetRef = useRef<boolean>(loadPersistedWidth() !== null);
// Auto-fit to the longest folder name on first render and whenever the
// folder list grows; skipped once the user manually resizes.
useEffect(() => {
if (userSetRef.current) return;
const auto = computeAutoFitWidth(folders.folders, rootLabel);
setWidth(auto);
}, [folders.folders, rootLabel]);
const dragStateRef = useRef<{
startX: number;
startWidth: number;
} | null>(null);
const onMouseMove = useCallback((e: MouseEvent) => {
const state = dragStateRef.current;
if (!state) return;
const next = clamp(state.startWidth + (e.clientX - state.startX));
setWidth(next);
}, []);
const onMouseUp = useCallback(() => {
const state = dragStateRef.current;
if (!state) return;
dragStateRef.current = null;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
userSetRef.current = true;
setWidth((current) => {
savePersistedWidth(current);
return current;
});
}, [onMouseMove]);
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStateRef.current = { startX: e.clientX, startWidth: width };
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[onMouseMove, onMouseUp, width],
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = e.shiftKey ? 32 : 8;
let next: number | null = null;
if (e.key === "ArrowLeft") next = clamp(width - step);
else if (e.key === "ArrowRight") next = clamp(width + step);
else if (e.key === "Home") next = MIN_WIDTH;
else if (e.key === "End") next = MAX_WIDTH;
if (next === null) return;
e.preventDefault();
userSetRef.current = true;
setWidth(next);
savePersistedWidth(next);
},
[width],
);
return (
<div
className="folder-tree-panel"
data-active={String(active)}
aria-hidden={!active}
style={
active
? ({
"--folder-tree-panel-width": `${width}px`,
} as React.CSSProperties)
: undefined
}
>
<div className="folder-tree-panel-inner">
<div className="folder-tree-panel-header">
<span className="folder-tree-panel-title">
{t("filesPage.myFiles", "My Files")}
</span>
</div>
<FolderTreeSidebar
fileCounts={fileCountsByFolder}
onRequestNewFolder={openNewFolderDialog}
onRenameFolder={(folder: FolderRecord) =>
openRenameFolderDialog(folder)
}
onDeleteFolder={promptDeleteFolder}
onMoveFilesIntoFolder={async (
targetId: FolderId | null,
fileIds: FileId[],
) => {
if (fileIds.length === 0) return;
await moveFilesTo(fileIds, targetId);
}}
/>
</div>
{active && (
<div
className="folder-tree-panel-resizer"
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
aria-valuenow={width}
aria-label={t(
"filesPage.resizeFolderTree",
"Resize folder tree (arrow keys, Shift for bigger steps)",
)}
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onKeyDown}
onDoubleClick={() => {
const auto = computeAutoFitWidth(folders.folders, rootLabel);
userSetRef.current = false;
setWidth(auto);
savePersistedWidth(auto);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,490 @@
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu } from "@mantine/core";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import HomeIcon from "@mui/icons-material/Home";
import DevicesOtherIcon from "@mui/icons-material/DevicesOther";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import EditIcon from "@mui/icons-material/Edit";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { useFolders } from "@app/contexts/FolderContext";
import { FileId } from "@app/types/file";
import {
FolderId,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
} from "@app/types/folder";
import { useFilesPage } from "@app/contexts/FilesPageContext";
import {
FILES_PAGE_DRAG_TYPE,
parseFilesPageDragPayload,
serialiseFilesPageDragPayload,
} from "@app/components/filesPage/dragDrop";
import { useDropTarget } from "@app/components/filesPage/useDropTarget";
/**
* Hard cap on folder-tree render depth. The backend already enforces an
* application-level depth limit via cycle detection + folder-count cap,
* and React's render stack handles ~50 nested components comfortably,
* so this is purely defensive against a corrupted IDB cache producing
* a chain deeper than the server would allow.
*/
const MAX_TREE_DEPTH = 50;
interface FolderTreeSidebarProps {
fileCounts: Map<FolderId | null, number>;
onRequestNewFolder: (parentId: FolderId | null) => void;
onRenameFolder: (folder: FolderRecord) => void;
onDeleteFolder: (folder: FolderRecord) => void;
/**
* Move the *dragged* files (from the drop payload) into the target folder.
* Earlier signature took only the folder id and the parent then used the
* current selection - which silently moved the wrong files whenever the
* user dragged something that wasn't in the selection.
*/
onMoveFilesIntoFolder: (
folderId: FolderId | null,
fileIds: FileId[],
) => Promise<void> | void;
}
// This component is always rendered inside FolderTreePanel, which supplies
// its own <aside> chrome and "New folder at root" toolbar control. An
// earlier `embed` prop selected between an embedded list and a standalone
// aside+header layout; the standalone layout was unused and its "New
// folder at root" ActionIcon was not gated by `serverReachable`, so if
// anyone re-wired the component into a non-embed surface they'd ship an
// always-enabled mutation button against a possibly-offline server.
// Deleted to remove the trap.
export function FolderTreeSidebar({
fileCounts,
onRequestNewFolder,
onRenameFolder,
onDeleteFolder,
onMoveFilesIntoFolder,
}: FolderTreeSidebarProps) {
const { t } = useTranslation();
const { tree, currentFolderId, setCurrentFolderId } = useFolders();
const { currentTab, setCurrentTab, moveFolderTo } = useFilesPage();
return (
<div
className="files-page-tree-list"
role="tree"
aria-label={t("filesPage.tree", "Folders")}
>
<RootRow
fileCount={fileCounts.get(ROOT_FOLDER_ID) ?? 0}
isActive={
currentFolderId === ROOT_FOLDER_ID &&
(currentTab === "all" || currentTab === "cloud")
}
onSelect={() => {
// Picking the root re-enters the cloud bucket - also switch out
// of any virtual tab so the user lands somewhere consistent.
if (currentTab !== "all" && currentTab !== "cloud") {
setCurrentTab("all");
}
setCurrentFolderId(ROOT_FOLDER_ID);
}}
onDropFiles={(fileIds) =>
onMoveFilesIntoFolder(ROOT_FOLDER_ID, fileIds)
}
/>
<LocalRow
isActive={currentTab === "local"}
onSelect={() => setCurrentTab("local")}
/>
{tree.map((node) => (
<TreeNodeRow
key={node.folder.id}
node={node}
fileCounts={fileCounts}
currentFolderId={currentFolderId}
// Same dance as RootRow: clicking a cloud folder must drop the
// virtual-tab highlight (Local/Recent/Shared), otherwise the row
// AND the tab both look "active" simultaneously.
onSelect={(id) => {
if (currentTab !== "all" && currentTab !== "cloud") {
setCurrentTab("all");
}
setCurrentFolderId(id);
}}
onMoveFolder={async (folderId, newParentId) => {
// Route through filesPage.moveFolderTo so the cycle case
// surfaces an error banner instead of silently no-op'ing.
await moveFolderTo(folderId, newParentId);
}}
onMoveFiles={onMoveFilesIntoFolder}
onRequestNewFolder={onRequestNewFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
/>
))}
</div>
);
}
interface RootRowProps {
fileCount: number;
isActive: boolean;
onSelect: () => void;
onDropFiles: (fileIds: FileId[]) => Promise<void> | void;
}
function RootRow({ fileCount, isActive, onSelect, onDropFiles }: RootRowProps) {
const { t } = useTranslation();
const { setError } = useFolders();
const { handlers, isOver } = useDropTarget({
dragType: FILES_PAGE_DRAG_TYPE,
onDrop: (e) => {
const payload = parseFilesPageDragPayload(e.dataTransfer);
if (!payload) return;
if (payload.kind === "files") {
Promise.resolve(onDropFiles(payload.fileIds)).catch((err) => {
console.error("[RootRow] file drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFilesFailedDetail", {
message: err.message,
defaultValue: `Could not move files: ${err.message}`,
})
: t("filesPage.error.moveFilesFailed", "Could not move files."),
);
});
}
},
});
return (
<div
role="treeitem"
aria-selected={isActive}
tabIndex={0}
className={`files-page-tree-node${isActive ? " is-active" : ""}${
isOver ? " is-drop-target" : ""
}`}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
{...handlers}
>
<span className="files-page-tree-spacer" />
<span className="files-page-tree-icon">
<HomeIcon fontSize="small" />
</span>
<span className="files-page-tree-name">
{t("filesPage.allFiles", "All files")}
</span>
<span className="files-page-tree-count">{fileCount}</span>
</div>
);
}
interface LocalRowProps {
isActive: boolean;
onSelect: () => void;
}
/**
* Pinned pseudo-folder row that selects the Local tab. Local files don't
* belong to a folder (folders are a cloud concept) so this row is not a
* drop target and has no count badge - the Local view scopes by predicate
* (`remoteStorageId == null`), not by folderId.
*/
function LocalRow({ isActive, onSelect }: LocalRowProps) {
const { t } = useTranslation();
return (
<div
role="treeitem"
aria-selected={isActive}
tabIndex={0}
className={`files-page-tree-node${isActive ? " is-active" : ""}`}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
>
<span className="files-page-tree-spacer" />
<span className="files-page-tree-icon">
<DevicesOtherIcon fontSize="small" />
</span>
<span className="files-page-tree-name">
{t("filesPage.tabName.local", "Local")}
</span>
</div>
);
}
interface TreeNodeRowProps {
node: FolderTreeNode;
fileCounts: Map<FolderId | null, number>;
currentFolderId: FolderId | null;
onSelect: (id: FolderId) => void;
onMoveFolder: (
folderId: FolderId,
newParentId: FolderId | null,
) => Promise<void> | void;
onMoveFiles: (
folderId: FolderId | null,
fileIds: FileId[],
) => Promise<void> | void;
onRequestNewFolder: (parentId: FolderId | null) => void;
onRenameFolder: (folder: FolderRecord) => void;
onDeleteFolder: (folder: FolderRecord) => void;
}
function TreeNodeRow({
node,
fileCounts,
currentFolderId,
onSelect,
onMoveFolder,
onMoveFiles,
onRequestNewFolder,
onRenameFolder,
onDeleteFolder,
}: TreeNodeRowProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
const { currentTab } = useFilesPage();
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
"Offline - folder changes are disabled.",
);
const [open, setOpen] = useState(true);
// Only highlight the folder row when we're actually in a cloud-rooted
// view. Otherwise (Local/Recent/Shared tabs) it'd compete with the tab
// highlight and confuse the user about "where they are".
const isActive =
currentFolderId === node.folder.id &&
(currentTab === "all" || currentTab === "cloud");
const hasChildren = node.children.length > 0;
const indent = useMemo(
() => ({ paddingLeft: `${14 + node.depth * 16}px` }),
[node.depth],
);
const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({
dragType: FILES_PAGE_DRAG_TYPE,
onDrop: (e) => {
const payload = parseFilesPageDragPayload(e.dataTransfer);
if (!payload) return;
if (payload.kind === "files") {
// Use payload.fileIds - not the current selection - so dragging a
// non-selected file moves *that* file. Surface failures via the
// shared error banner rather than letting them become unhandled
// rejections that only the dev console sees.
Promise.resolve(onMoveFiles(node.folder.id, payload.fileIds)).catch(
(err) => {
console.error("[TreeNodeRow] file drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFilesFailedDetail", {
message: err.message,
defaultValue: `Could not move files: ${err.message}`,
})
: t("filesPage.error.moveFilesFailed", "Could not move files."),
);
},
);
} else if (payload.kind === "folder") {
Promise.resolve(onMoveFolder(payload.folderId, node.folder.id)).catch(
(err) => {
console.error("[TreeNodeRow] folder drop failed", err);
setError(
err instanceof Error
? t("filesPage.error.moveFolderFailedDetail", {
message: err.message,
defaultValue: `Could not move folder: ${err.message}`,
})
: t(
"filesPage.error.moveFolderFailed",
"Could not move folder.",
),
);
},
);
}
},
});
const handleDragStart = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData(
FILES_PAGE_DRAG_TYPE,
serialiseFilesPageDragPayload({
kind: "folder",
folderId: node.folder.id,
}),
);
e.dataTransfer.effectAllowed = "move";
},
[node.folder.id],
);
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<div
role="treeitem"
aria-selected={isActive}
aria-expanded={hasChildren ? open : undefined}
tabIndex={0}
draggable
style={indent}
className={`files-page-tree-node${isActive ? " is-active" : ""}${
isDropTarget ? " is-drop-target" : ""
}`}
onClick={() => onSelect(node.folder.id)}
onDoubleClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
onContextMenu={(e) => {
// Open the action menu on right-click rather than a native
// window.prompt (unstyled, untranslatable, unusable on mobile).
e.preventDefault();
setMenuOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node.folder.id);
} else if (e.key === "ArrowRight" && hasChildren) {
setOpen(true);
} else if (e.key === "ArrowLeft") {
setOpen(false);
}
}}
{...dropHandlers}
onDragStart={handleDragStart}
>
{hasChildren ? (
<span
className="files-page-tree-toggle"
aria-hidden="true"
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
>
{open ? (
<KeyboardArrowDownIcon fontSize="small" />
) : (
<KeyboardArrowRightIcon fontSize="small" />
)}
</span>
) : (
<span className="files-page-tree-spacer" />
)}
<span className="files-page-tree-icon">
<FolderThumbnail color={node.folder.color} size="tree" />
</span>
<span className="files-page-tree-name">{node.folder.name}</span>
<span className="files-page-tree-count">
{fileCounts.get(node.folder.id) ?? 0}
</span>
<Menu
opened={menuOpen}
onChange={setMenuOpen}
withinPortal
position="bottom-end"
shadow="md"
width={200}
>
<Menu.Target>
<ActionIcon
size="xs"
variant="subtle"
className="files-page-tree-kebab"
aria-label={t(
"filesPage.treeMenu.actions",
"Folder actions for {{name}}",
{ name: node.folder.name },
)}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((o) => !o);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRenameFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.rename", "Rename")}
</Menu.Item>
<Menu.Item
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRequestNewFolder(node.folder.id);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
{open &&
// Cap render recursion at MAX_TREE_DEPTH to guarantee a finite
// call stack even if a future bug (or a hand-edited IDB cache)
// produces a folder chain deeper than the server enforces. Any
// realistic user tree stays well under this; the cap exists so
// the renderer fails closed rather than blowing the JS stack.
node.depth < MAX_TREE_DEPTH &&
node.children.map((child) => (
<TreeNodeRow
key={child.folder.id}
node={child}
fileCounts={fileCounts}
currentFolderId={currentFolderId}
onSelect={onSelect}
onMoveFolder={onMoveFolder}
onMoveFiles={onMoveFiles}
onRequestNewFolder={onRequestNewFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
/>
))}
</>
);
}
@@ -0,0 +1,357 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import HomeIcon from "@mui/icons-material/Home";
import FolderIcon from "@mui/icons-material/Folder";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
interface MoveToFolderDialogProps {
opened: boolean;
onClose: () => void;
folders: FolderRecord[];
/** Folder being moved; excludes its descendants from destinations. */
disabledFolderId?: FolderId | null;
initialFolderId?: FolderId | null;
onConfirm: (folderId: FolderId | null) => void | Promise<void>;
/** Inline-create folder; new folder becomes the move target. */
onCreateFolder?: (
name: string,
parentFolderId: FolderId | null,
) => Promise<FolderRecord>;
}
export function MoveToFolderDialog({
opened,
onClose,
folders,
disabledFolderId,
initialFolderId = ROOT_FOLDER_ID,
onConfirm,
onCreateFolder,
}: MoveToFolderDialogProps) {
const { t } = useTranslation();
const [target, setTarget] = useState<FolderId | null>(initialFolderId);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Inline create-folder state; revealed by the toggle.
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creating, setCreating] = useState(false);
// Reset when reopening with a new initial.
React.useEffect(() => {
if (opened) {
setTarget(initialFolderId);
setSubmitting(false);
setError(null);
setCreatingFolder(false);
setNewFolderName("");
setCreating(false);
}
}, [opened, initialFolderId]);
/** Single-pass build of parent index, depths, and blocked descendants. */
const { depthById, blocked, treeOrder } = useMemo(() => {
const byParent = new Map<FolderId | null, FolderRecord[]>();
for (const folder of folders) {
const list = byParent.get(folder.parentFolderId) ?? [];
list.push(folder);
byParent.set(folder.parentFolderId, list);
}
for (const list of byParent.values()) {
list.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}
// Pre-order DFS; truncates past MAX_TREE_DEPTH to prevent stack overflow.
const MAX_TREE_DEPTH = 50;
const order: FolderRecord[] = [];
const depths = new Map<FolderId, number>();
const visit = (parent: FolderId | null, depth: number) => {
if (depth >= MAX_TREE_DEPTH) return;
for (const child of byParent.get(parent) ?? []) {
order.push(child);
depths.set(child.id, depth);
visit(child.id, depth + 1);
}
};
visit(null, 0);
const blockedSet = new Set<FolderId>();
if (disabledFolderId) {
const stack: FolderId[] = [disabledFolderId];
while (stack.length > 0) {
const cur = stack.pop()!;
if (blockedSet.has(cur)) continue;
blockedSet.add(cur);
for (const child of byParent.get(cur) ?? []) {
stack.push(child.id);
}
}
}
return { depthById: depths, blocked: blockedSet, treeOrder: order };
}, [disabledFolderId, folders]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t("filesPage.moveDialog.title", "Move to folder")}
centered
size="md"
keepMounted
transitionProps={{ duration: 0 }}
>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t(
"filesPage.moveDialog.hint",
"Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left.",
)}
</Text>
<div
style={{
border: "1px solid var(--border-subtle)",
borderRadius: "0.5rem",
maxHeight: "20rem",
overflowY: "auto",
}}
>
<FolderPick
label={t("filesPage.allFiles", "All files")}
isActive={target === ROOT_FOLDER_ID}
disabled={false}
depth={0}
isRoot
onPick={() => setTarget(ROOT_FOLDER_ID)}
/>
{treeOrder.map((folder) => (
<FolderPick
key={folder.id}
label={folder.name}
color={folder.color}
isActive={target === folder.id}
disabled={blocked.has(folder.id)}
depth={depthById.get(folder.id) ?? 0}
onPick={() => setTarget(folder.id)}
/>
))}
</div>
{/* Inline Create new folder; new folder becomes the move target. */}
{onCreateFolder &&
(() => {
const trimmedName = newFolderName.trim();
const handleCreate = async () => {
if (trimmedName.length === 0) return;
setCreating(true);
setError(null);
try {
const created = await onCreateFolder(
trimmedName,
// ROOT becomes null parent.
target === ROOT_FOLDER_ID ? null : target,
);
setTarget(created.id);
setCreatingFolder(false);
setNewFolderName("");
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.moveDialog.newFolderError",
"Could not create folder. Try again.",
),
);
} finally {
setCreating(false);
}
};
const handleCancel = () => {
setCreatingFolder(false);
setNewFolderName("");
};
return creatingFolder ? (
<Group gap="xs" align="flex-end" wrap="nowrap">
<TextInput
label={t(
"filesPage.moveDialog.newFolderLabel",
"New folder name",
)}
value={newFolderName}
onChange={(e) => setNewFolderName(e.currentTarget.value)}
placeholder={t(
"filesPage.moveDialog.newFolderPlaceholder",
"Folder name",
)}
style={{ flex: 1 }}
disabled={creating}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
}}
autoFocus
/>
<Button
loading={creating}
disabled={trimmedName.length === 0}
onClick={handleCreate}
>
{t("filesPage.moveDialog.newFolderCreate", "Create")}
</Button>
{/* X collapses the inline create row only. */}
<Tooltip
label={t("filesPage.moveDialog.newFolderCancel", "Discard")}
withinPortal
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleCancel}
disabled={creating}
aria-label={t(
"filesPage.moveDialog.newFolderCancel",
"Discard",
)}
>
<CloseIcon fontSize="small" />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => {
setCreatingFolder(true);
setNewFolderName("");
}}
styles={{ root: { alignSelf: "flex-start" } }}
>
{t(
"filesPage.moveDialog.newFolderToggle",
"Create new folder…",
)}
</Button>
);
})()}
{error && (
<Alert
color="red"
icon={<ErrorOutlineIcon fontSize="small" />}
variant="light"
role="alert"
>
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
{t("filesPage.moveDialog.cancel", "Cancel")}
</Button>
<Button
loading={submitting}
onClick={async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm(target);
onClose();
} catch (err) {
setError(
err instanceof Error
? err.message
: t(
"filesPage.moveDialog.error",
"Could not move. Try again.",
),
);
} finally {
setSubmitting(false);
}
}}
>
{t("filesPage.moveDialog.confirm", "Move here")}
</Button>
</Group>
</Stack>
</Modal>
);
}
interface FolderPickProps {
label: string;
color?: string;
isActive: boolean;
disabled: boolean;
depth: number;
isRoot?: boolean;
onPick: () => void;
}
function FolderPick({
label,
color,
isActive,
disabled,
depth,
isRoot,
onPick,
}: FolderPickProps) {
return (
<button
type="button"
onClick={onPick}
disabled={disabled}
style={{
all: "unset",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
width: "100%",
background: isActive ? "var(--hover-bg)" : "transparent",
borderBottom: "1px solid var(--border-subtle)",
boxSizing: "border-box",
fontWeight: isActive ? 600 : 400,
}}
>
{isRoot ? (
<HomeIcon fontSize="small" />
) : isActive ? (
<FolderOpenIcon fontSize="small" style={{ color }} />
) : (
<FolderIcon fontSize="small" style={{ color }} />
)}
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
</button>
);
}
@@ -0,0 +1,39 @@
import { FileId } from "@app/types/file";
import { FolderId } from "@app/types/folder";
/**
* Custom MIME used to flag drag operations originating inside the
* file manager page. Using a custom type avoids clashing with native
* file/text drags from the OS.
*/
export const FILES_PAGE_DRAG_TYPE = "application/x-stirling-files-page";
export type FilesPageDragPayload =
| { kind: "files"; fileIds: FileId[] }
| { kind: "folder"; folderId: FolderId };
export function serialiseFilesPageDragPayload(
payload: FilesPageDragPayload,
): string {
return JSON.stringify(payload);
}
export function parseFilesPageDragPayload(
dataTransfer: DataTransfer,
): FilesPageDragPayload | null {
if (!dataTransfer.types.includes(FILES_PAGE_DRAG_TYPE)) return null;
const raw = dataTransfer.getData(FILES_PAGE_DRAG_TYPE);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as FilesPageDragPayload;
if (parsed.kind === "files" && Array.isArray(parsed.fileIds)) {
return parsed;
}
if (parsed.kind === "folder" && typeof parsed.folderId === "string") {
return parsed;
}
return null;
} catch {
return null;
}
}
@@ -0,0 +1,24 @@
/**
* Classifies where a stored file lives. The UI uses this to badge each file
* (Local vs Cloud) and to drive the origin filter chip.
*
* - "local" - only in the browser's IndexedDB
* - "cloud" - also exists on the server (uploaded), still owned by the user
* - "shared-with-me" - opened from a share link / not owned by current user
*/
import { StirlingFileStub } from "@app/types/fileContext";
export type FileOrigin = "local" | "cloud" | "shared-with-me";
export const FILE_ORIGINS: FileOrigin[] = ["local", "cloud", "shared-with-me"];
export function getFileOrigin(file: StirlingFileStub): FileOrigin {
if (file.remoteSharedViaLink || file.remoteOwnedByCurrentUser === false) {
return "shared-with-me";
}
if (file.remoteStorageId) {
return "cloud";
}
return "local";
}
@@ -0,0 +1,91 @@
/**
* Stores the route the user came from when they open files into the
* workbench from My Files. Lets the workbench show a "Back to My Files"
* affordance and return to the exact folder they were browsing.
*
* Persisted in sessionStorage so a hard reload keeps the return path
* (matches user mental model - Cmd+R shouldn't lose the breadcrumb).
*/
const SESSION_KEY = "stirling.filesPage.returnRoute";
const SESSION_LABEL_KEY = "stirling.filesPage.returnLabel";
export interface FilesPageReturnRoute {
route: string;
label?: string;
}
/**
* Cached snapshot so `useSyncExternalStore` returns a stable reference
* across consecutive renders. The cache is invalidated whenever the
* sessionStorage entry changes (via set/clear or storage event).
*/
let cachedSnapshot: FilesPageReturnRoute | null = null;
let cachedSerialised = "";
function readFromStorage(): FilesPageReturnRoute | null {
try {
const route = sessionStorage.getItem(SESSION_KEY);
if (!route) return null;
const label = sessionStorage.getItem(SESSION_LABEL_KEY) ?? undefined;
return { route, label };
} catch {
return null;
}
}
function refreshSnapshot(): void {
const next = readFromStorage();
const serialised = next ? `${next.route}|${next.label ?? ""}` : "";
if (serialised !== cachedSerialised) {
cachedSnapshot = next;
cachedSerialised = serialised;
}
}
export function setFilesPageReturnRoute(route: string, label?: string): void {
try {
sessionStorage.setItem(SESSION_KEY, route);
if (label) sessionStorage.setItem(SESSION_LABEL_KEY, label);
else sessionStorage.removeItem(SESSION_LABEL_KEY);
refreshSnapshot();
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
} catch {
/* ignore */
}
}
export function clearFilesPageReturnRoute(): void {
try {
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_LABEL_KEY);
refreshSnapshot();
window.dispatchEvent(new CustomEvent("stirling-filespage-return-changed"));
} catch {
/* ignore */
}
}
export function getFilesPageReturnRoute(): FilesPageReturnRoute | null {
return cachedSnapshot;
}
/** Subscribe to changes (storage + same-tab CustomEvent). */
export function subscribeFilesPageReturnRoute(
listener: () => void,
): () => void {
const handler = () => {
refreshSnapshot();
listener();
};
window.addEventListener("storage", handler);
window.addEventListener("stirling-filespage-return-changed", handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("stirling-filespage-return-changed", handler);
};
}
// Initial read on module load so the first useSyncExternalStore snapshot
// is correct even before any setters fire.
refreshSnapshot();
@@ -0,0 +1,41 @@
/**
* Folder icon presets. Each is a single emoji that overlays the folder
* thumbnail. Picked to cover the most common organisational uses of a
* folder without resorting to a custom icon system.
*
* Kept small (≤24) so the picker is one glanceable row in the menu.
*/
export interface FolderIconOption {
id: string;
glyph: string;
label: string;
}
export const FOLDER_ICONS: FolderIconOption[] = [
{ id: "none", glyph: "", label: "No icon" },
{ id: "star", glyph: "★", label: "Star" },
{ id: "heart", glyph: "♥", label: "Heart" },
{ id: "work", glyph: "💼", label: "Work" },
{ id: "home", glyph: "🏠", label: "Home" },
{ id: "tax", glyph: "💰", label: "Money" },
{ id: "receipt", glyph: "🧾", label: "Receipt" },
{ id: "contract", glyph: "📝", label: "Contract" },
{ id: "id", glyph: "🪪", label: "ID" },
{ id: "house", glyph: "🏡", label: "House" },
{ id: "travel", glyph: "✈️", label: "Travel" },
{ id: "photos", glyph: "🖼️", label: "Photos" },
{ id: "music", glyph: "🎵", label: "Music" },
{ id: "code", glyph: "💻", label: "Code" },
{ id: "health", glyph: "🏥", label: "Health" },
{ id: "school", glyph: "🎓", label: "School" },
{ id: "warning", glyph: "⚠️", label: "Warning" },
{ id: "archive", glyph: "📦", label: "Archive" },
];
export function findFolderIcon(
id: string | undefined,
): FolderIconOption | null {
if (!id) return null;
return FOLDER_ICONS.find((i) => i.id === id) ?? null;
}
@@ -0,0 +1,83 @@
import { FolderRecord } from "@app/types/folder";
const STORAGE_KEY = "stirling-folder-tree-width";
export const MIN_WIDTH = 210;
export const MAX_WIDTH = 480;
const DEFAULT_WIDTH = 272;
const ROW_FONT =
'14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
const COUNT_FONT =
'12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
/** Per-row chrome: left padding + toggle + icon + name margin + count margin + right padding. */
const ROW_CHROME = 14 + 16 + 18 + 8 + 12 + 8 + 14;
const INDENT_PER_LEVEL = 16;
function loadCanvas(): CanvasRenderingContext2D | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
return canvas.getContext("2d");
}
function depthOf(
folder: FolderRecord,
byId: Map<string, FolderRecord>,
): number {
let depth = 0;
let cursor: FolderRecord | undefined = folder;
while (cursor && cursor.parentFolderId) {
depth += 1;
cursor = byId.get(cursor.parentFolderId as string);
if (depth > 50) break;
}
return depth;
}
/** Width needed to fit the longest folder row, clamped to [MIN, MAX]. */
export function computeAutoFitWidth(
folders: FolderRecord[],
rootLabel: string,
): number {
const ctx = loadCanvas();
if (!ctx) return DEFAULT_WIDTH;
const byId = new Map(folders.map((f) => [f.id as string, f]));
let maxName = 0;
let maxDepth = 0;
ctx.font = ROW_FONT;
const measure = (name: string) => Math.ceil(ctx.measureText(name).width);
maxName = Math.max(maxName, measure(rootLabel));
for (const f of folders) {
const w = measure(f.name);
const d = depthOf(f, byId);
if (w > maxName) maxName = w;
if (d > maxDepth) maxDepth = d;
}
ctx.font = COUNT_FONT;
// 4 digits covers typical counts (9999).
const countWidth = Math.ceil(ctx.measureText("9999").width);
const width = ROW_CHROME + maxDepth * INDENT_PER_LEVEL + maxName + countWidth;
return clamp(width);
}
export function clamp(width: number): number {
if (Number.isNaN(width)) return DEFAULT_WIDTH;
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
}
export function loadPersistedWidth(): number | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage?.getItem(STORAGE_KEY);
if (raw == null) return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
return clamp(parsed);
}
export function savePersistedWidth(width: number): void {
if (typeof window === "undefined") return;
try {
window.localStorage?.setItem(STORAGE_KEY, String(clamp(width)));
} catch {
// localStorage can throw in private mode or when over quota; ignore.
}
}
@@ -0,0 +1,121 @@
/**
* useDropTarget - stable hover-state tracking for drop targets.
*
* The default HTML5 drag-and-drop API fires `dragenter`/`dragleave` for every
* child element the pointer crosses, which makes naive `setHover(true/false)`
* implementations flicker. This hook tracks enter/leave with a counter so the
* hover state only resets when the pointer truly leaves the bounding box, and
* caches the last `dropEffect`/payload to avoid redundant state updates.
*/
import {
DragEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from "react";
interface UseDropTargetOptions {
/**
* MIME type of the drag payload to react to. dragover/drop events for
* other payloads are ignored so external file drags still bubble up to
* the page-level drop zone.
*/
dragType: string;
/** Fired when the user releases over the target. */
onDrop: (event: React.DragEvent<HTMLElement>) => void;
/** Optional CSS effect - defaults to "move". */
dropEffect?: DataTransfer["dropEffect"];
/** Disable the target without unmounting. */
disabled?: boolean;
}
export interface DropTargetBinding {
/** Bind to the element you want to act as a drop target. */
handlers: {
onDragEnter: DragEventHandler<HTMLElement>;
onDragOver: DragEventHandler<HTMLElement>;
onDragLeave: DragEventHandler<HTMLElement>;
onDrop: DragEventHandler<HTMLElement>;
};
/** True while the pointer is over the element (or any of its children). */
isOver: boolean;
}
export function useDropTarget({
dragType,
onDrop,
dropEffect = "move",
disabled,
}: UseDropTargetOptions): DropTargetBinding {
const [isOver, setIsOver] = useState(false);
const counter = useRef(0);
// If the element gets unmounted mid-drag, reset state.
useEffect(
() => () => {
counter.current = 0;
},
[],
);
const accepts = useCallback(
(e: React.DragEvent<HTMLElement>) =>
e.dataTransfer.types.includes(dragType),
[dragType],
);
const handleDragEnter = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
counter.current += 1;
if (!isOver) setIsOver(true);
},
[accepts, disabled, isOver],
);
const handleDragOver = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = dropEffect;
if (!isOver) setIsOver(true);
},
[accepts, disabled, dropEffect, isOver],
);
const handleDragLeave = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
counter.current -= 1;
if (counter.current <= 0) {
counter.current = 0;
setIsOver(false);
}
},
[accepts, disabled],
);
const handleDrop = useCallback<DragEventHandler<HTMLElement>>(
(e) => {
if (disabled || !accepts(e)) return;
e.preventDefault();
counter.current = 0;
setIsOver(false);
onDrop(e);
},
[accepts, disabled, onDrop],
);
return {
handlers: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
isOver,
};
}
@@ -27,6 +27,9 @@ const PageEditorControls = lazy(
() => import("@app/components/pageEditor/PageEditorControls"),
);
const Viewer = lazy(() => import("@app/components/viewer/Viewer"));
const FileManagerView = lazy(
() => import("@app/components/filesPage/FileManagerView"),
);
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -99,6 +102,12 @@ export default function Workbench() {
}
}
// The "My Files" workbench is available regardless of whether files are
// currently loaded into the workbench - it lives on top of the IDB store.
if (currentView === "myFiles") {
return <FileManagerView />;
}
if (activeFiles.length === 0) {
return <LandingPage />;
}
@@ -183,27 +192,31 @@ export default function Workbench() {
data-tour="workbench"
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: "var(--bg-background)" }
? // No background color in rainbow mode, but still pin min-width:0
// so inner flex children (files-page toolbar, etc.) actually
// shrink on narrow viewports.
{ minWidth: 0 }
: { backgroundColor: "var(--bg-background)", minWidth: 0 }
}
>
{/* Workbench Bar - animates in/out based on file presence */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
{currentView !== "myFiles" &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
</div>
</div>
</div>
)}
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -213,6 +226,11 @@ export default function Workbench() {
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: "opacity 0.15s ease-in-out",
// Force min-width:0 so flex children (notably the files page
// toolbar with its 5 bulk-action buttons + 2 selects + view
// toggle) can shrink below their intrinsic content size on
// narrow viewports instead of overflowing horizontally.
minWidth: 0,
...(currentView === "pageEditor" && { height: 0 }),
}}
>
@@ -74,12 +74,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
}
}, [opened]);
// Handle custom events for backwards compatibility
// Handle custom events for backwards compatibility.
// Use replace when already on /settings/* so external tab-switches
// don't pile up history entries that would break close-by-back.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
navigate(`/settings/${detail.key}`);
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
navigate(`/settings/${detail.key}`, {
replace: alreadyInSettings,
});
}
};
window.addEventListener("appConfig:navigate", handler as EventListener);
@@ -112,10 +118,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const canProceed = await confirmIfDirty();
if (!canProceed) return;
// Navigate back to home when closing modal
navigate("/", { replace: true });
// Pop back to whatever the user came from (files / viewer / tools).
// location.key === "default" means /settings was the first entry in
// this tab (deep link / refresh), so there's nothing to pop to;
// fall back to home in that case.
if (location.key === "default") {
navigate("/", { replace: true });
} else {
navigate(-1);
}
onClose();
}, [confirmIfDirty, navigate, onClose]);
}, [confirmIfDirty, location.key, navigate, onClose]);
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
const handleCloseSync = useCallback(() => {
@@ -152,7 +165,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
if (!canProceed) return;
setActive(key);
navigate(`/settings/${key}`);
// First in-modal nav (when current path isn't `/settings/*` yet) must
// PUSH so the originating page stays in history and close-by-back can
// return to it. Subsequent tab switches REPLACE so they don't pile up
// history entries that handleClose's navigate(-1) can't unwind.
//
// Read window.location.pathname directly (not the React hook's
// location.pathname) so rapid successive clicks pick up the URL
// change from the previous click immediately. The hook snapshot is
// stale between render cycles - relying on it lets a second click
// PUSH again before React re-renders, producing a history pile-up.
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
navigate(`/settings/${key}`, { replace: alreadyInSettings });
},
[confirmIfDirty, navigate],
);
@@ -8,10 +8,9 @@
height: 100%;
position: relative;
z-index: 10;
transition:
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* Animating width + min-width + max-width together can leave the flex layout
stuck on the pre-animation size in some browsers. Snap instead and rely
on the inner content fade for visual smoothness. */
flex-shrink: 0;
}
@@ -40,7 +39,7 @@
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
.file-sidebar-header:hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
}
.file-sidebar-menu-icon {
@@ -49,6 +48,18 @@
flex-shrink: 0;
}
/* Inherits font-size so swap-in icons render at 18px like the original. */
.file-sidebar-menu-icon > svg {
font-size: inherit;
width: 1em;
height: 1em;
}
/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */
[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg {
transform: scaleX(-1);
}
.file-sidebar-brand-text {
height: 22px;
width: auto;
@@ -70,7 +81,7 @@
}
.file-sidebar-search-row:not(.active):hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
}
.file-sidebar-search-icon {
@@ -105,7 +116,7 @@
}
/* ---- Scrollable content ---- */
/* This is a flex column action rows are fixed, only the file list scrolls */
/* This is a flex column - action rows are fixed, only the file list scrolls */
.file-sidebar-scroll {
flex: 1;
min-height: 0;
@@ -140,13 +151,36 @@
}
.file-sidebar-action-row:hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
}
.file-sidebar-action-row.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: auto;
}
.file-sidebar-action-row.disabled:hover {
background-color: transparent;
}
.file-sidebar-action-icon {
color: var(--text-muted) !important;
font-size: 18px !important;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
/* When the icon is a wrapper around an SVG (extraAction case), force
* the SVG to inherit the wrapper's 18px sizing instead of MUI's default
* 1.5rem so it matches the other rail icons. */
.file-sidebar-action-icon > svg {
font-size: inherit;
width: 1em;
height: 1em;
}
.file-sidebar-action-label {
@@ -171,7 +205,7 @@
}
.file-sidebar-cloud-row:not(.disabled):hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
}
.file-sidebar-cloud-row.disabled {
@@ -296,7 +330,7 @@
}
.file-sidebar-section-btn:hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
color: var(--text-primary);
}
@@ -376,7 +410,7 @@
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
@@ -5,10 +5,10 @@ import React, {
useEffect,
forwardRef,
} from "react";
import { Loader } from "@mantine/core";
import { Loader, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import {
@@ -26,6 +26,7 @@ import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
@@ -41,11 +42,40 @@ export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
onOpenSettings?: () => void;
/** Accessible name override for the toggle button. */
toggleAriaLabel?: string;
/** Icon override for the toggle button (e.g. back-arrow on /files). */
toggleIcon?: React.ReactNode;
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
onUploadFiles?: (files: File[]) => void | Promise<void>;
/** Override the Google Drive handler. */
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
/** Override the Search row click (e.g. focus the /files search input). */
onSearchClick?: () => void;
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
extraAction?: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
disabledTooltip?: string;
testId?: string;
};
}
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
function FileSidebar(
{ collapsed = false, onToggleCollapse, onOpenSettings },
{
collapsed = false,
onToggleCollapse,
onOpenSettings,
toggleAriaLabel,
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
onSearchClick,
extraAction,
},
ref,
) {
const { t } = useTranslation();
@@ -53,12 +83,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const nativeFileInputRef = useRef<HTMLInputElement>(null);
// State (not ref) so setting it triggers a re-render avoids racing addFiles state updates.
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
null,
);
const { openFilesModal } = useFilesModalContext();
const navigate = useNavigate();
const { config } = useAppConfig();
const {
isEnabled: isGoogleDriveEnabled,
@@ -93,7 +123,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const [stubsLoaded, setStubsLoaded] = useState(false);
const refreshStubs = useCallback(async () => {
// Leaf files from IDB same source as the file selection modal.
// Leaf files from IDB - same source as the file selection modal.
const stubs = await indexedDB.loadLeafMetadata();
const idbIds = new Set(stubs.map((s) => s.id as string));
@@ -138,11 +168,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// Handle search activation
const handleSearchClick = useCallback(() => {
if (onSearchClick) {
onSearchClick();
return;
}
if (collapsed && onToggleCollapse) {
onToggleCollapse();
}
setSearchActive(true);
}, [collapsed, onToggleCollapse]);
}, [collapsed, onToggleCollapse, onSearchClick]);
const handleSearchClose = useCallback(() => {
setSearchActive(false);
@@ -159,11 +193,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const handleGoogleDriveClick = useCallback(async () => {
if (!isGoogleDriveEnabled) return;
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
if (files.length === 0) return;
if (onPickGoogleDriveFiles) {
await onPickGoogleDriveFiles(files);
return;
}
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
}, [
isGoogleDriveEnabled,
@@ -171,6 +208,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
addFiles,
navActions,
isMultiTool,
onPickGoogleDriveFiles,
]);
// Toggle file in/out of workbench
@@ -195,7 +233,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
}
await fileActions.removeFiles([workbenchFileId], false);
} else {
// Re-add by stub to preserve its ID addFiles() would create a new UUID + IDB entry.
// Re-add by stub to preserve its ID - addFiles() would create a new UUID + IDB entry.
const workbenchCount = state.files.ids.length;
if (workbenchCount > 0 && currentWorkbench === "viewer") {
@@ -231,7 +269,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
],
);
// Which file is currently open in the viewer stable ID, never index-derived.
// Which file is currently open in the viewer - stable ID, never index-derived.
const viewedWorkbenchId =
currentWorkbench === "viewer" ? activeFileId : null;
@@ -246,12 +284,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
);
if (isCurrentlyViewed) {
// Closing the currently-viewed file guard against unsaved changes.
// Closing the currently-viewed file - guard against unsaved changes.
navActions.setWorkbench("fileEditor");
return;
}
// Switching to a different file while viewer is open guard against unsaved changes.
// Switching to a different file while viewer is open - guard against unsaved changes.
const performSwitch = async () => {
const alreadyInWorkbench = state.files.ids.some(
(id) => (id as string) === (stub.id as string),
@@ -291,18 +329,23 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const handleNativeFilePick = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
// Per-tool validation happens downstream.
const files = Array.from(e.target.files ?? []);
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(
files.length === 1 ? "viewer" : "fileEditor",
);
if (onUploadFiles) {
await onUploadFiles(files);
} else {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(
files.length === 1 ? "viewer" : "fileEditor",
);
}
}
}
e.target.value = "";
},
[addFiles, navActions, isMultiTool],
[addFiles, navActions, isMultiTool, onUploadFiles],
);
const shouldHideGoogleDrive =
@@ -321,29 +364,53 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<div
className="file-sidebar-header"
onClick={() => onToggleCollapse?.()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
<Tooltip
label={toggleAriaLabel ?? t("fileSidebar.expand", "Expand sidebar")}
position="right"
withinPortal
disabled={!collapsed}
>
<MenuIcon className="file-sidebar-menu-icon" />
{!collapsed && (
<Wordmark
alt="Stirling PDF"
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
</div>
<div
className="file-sidebar-header"
onClick={() => onToggleCollapse?.()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleCollapse?.();
}
}}
aria-label={
toggleAriaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
>
{/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */}
<span
className="file-sidebar-menu-icon"
data-toggle-flip-rtl={toggleIcon ? "true" : undefined}
>
{toggleIcon ?? <MenuIcon />}
</span>
{!collapsed && (
<Wordmark
alt="Stirling PDF"
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
</div>
</Tooltip>
{/* Search row */}
{
<Tooltip
label={t("fileSidebar.search", "Search")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
@@ -385,70 +452,204 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</span>
))}
</div>
}
</Tooltip>
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Open from Computer + Google Drive */}
{
<>
{/* Hidden native file input - kept outside the !collapsed gate so
the "Open from computer" row below (always rendered) can fire
it in either sidebar state without a silent no-op. */}
<input
ref={nativeFileInputRef}
type="file"
multiple
// No `accept` filter - this picker feeds the global workspace,
// not a specific tool, so users may legitimately upload PNGs,
// ZIPs, etc. for the convert/merge/extract tools to handle.
style={{ display: "none" }}
onChange={handleNativeFilePick}
data-testid="file-input"
/>
{/* Open from Computer + My Files + Google Drive */}
{/* Tooltips only fire when collapsed - when expanded the visible
text label below already identifies each row, so a tooltip
would just flash a duplicate. Distinct icons (UploadFile for
"Open from computer" vs FolderOpen for "My Files") so the
collapsed rail isn't two identical folder icons either. */}
<Tooltip
label={t("fileSidebar.openFromComputer", "Open from computer")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-action-row"
// `files-button` is the long-standing upload entry-point
// testid: click + setInputFiles on `file-input` above. Tour
// anchor lives here too - the tour now spotlights the native
// picker shortcut rather than the old modal.
data-testid="files-button"
data-tour="files-button"
onClick={() => {
// "Open from computer" goes straight to the native OS file
// picker. The full file manager (recent + drives + folders)
// is reachable via "My Files" below.
nativeFileInputRef.current?.click();
}}
role="button"
tabIndex={0}
aria-label={t(
"fileSidebar.openFromComputer",
"Open from computer",
)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
nativeFileInputRef.current?.click();
}
}}
>
<UploadFileIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.openFromComputer", "Open from computer")}
</span>
)}
</div>
</Tooltip>
{extraAction && (
<Tooltip
label={extraAction.disabledTooltip ?? extraAction.label}
position="right"
withinPortal
// Only force a wide multiline box when the long disabled
// reason is shown; the short label fits one line.
multiline={Boolean(
extraAction.disabled && extraAction.disabledTooltip,
)}
w={
extraAction.disabled && extraAction.disabledTooltip
? 220
: undefined
}
disabled={
!collapsed &&
!(extraAction.disabled && extraAction.disabledTooltip)
}
>
<div
className="file-sidebar-action-row"
data-testid="files-button"
data-tour="files-button"
className={`file-sidebar-action-row${extraAction.disabled ? " disabled" : ""}`}
data-testid={extraAction.testId}
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
if (extraAction.disabled) return;
extraAction.onClick();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
tabIndex={extraAction.disabled ? -1 : 0}
aria-disabled={extraAction.disabled}
aria-label={extraAction.label}
onKeyDown={(e) => {
if (extraAction.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
extraAction.onClick();
}
}}
>
<FolderOpenIcon className="file-sidebar-action-icon" />
<span className="file-sidebar-action-icon">
{extraAction.icon}
</span>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.openFromComputer", "Open from computer")}
{extraAction.label}
</span>
)}
</div>
</Tooltip>
)}
{!shouldHideGoogleDrive && (
<div
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
role="button"
tabIndex={isGoogleDriveEnabled ? 0 : -1}
aria-disabled={!isGoogleDriveEnabled}
title={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
}
>
<div className="file-sidebar-cloud-icon-wrapper">
<Tooltip
label={t("fileSidebar.myFiles", "My Files")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-action-row"
data-testid="my-files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
navigate("/files");
}}
role="button"
tabIndex={0}
aria-label={t("fileSidebar.myFiles", "My Files")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/files");
}
}}
>
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.myFiles", "My Files")}
</span>
)}
</div>
</Tooltip>
{!shouldHideGoogleDrive && (
<Tooltip
label={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
role="button"
tabIndex={isGoogleDriveEnabled ? 0 : -1}
aria-disabled={!isGoogleDriveEnabled}
aria-label={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
}
>
<div className="file-sidebar-cloud-icon-wrapper">
<GoogleDriveIcon
className="file-sidebar-cloud-icon-gray"
style={{ color: "var(--text-secondary)" }}
/>
{isGoogleDriveEnabled && (
<GoogleDriveIcon
className="file-sidebar-cloud-icon-gray"
style={{ color: "var(--text-secondary)" }}
colored
className="file-sidebar-cloud-icon-color"
/>
{isGoogleDriveEnabled && (
<GoogleDriveIcon
colored
className="file-sidebar-cloud-icon-color"
/>
)}
</div>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
</span>
)}
</div>
)}
</>
}
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
</span>
)}
</div>
</Tooltip>
)}
{/* Files section - always visible when expanded */}
{!collapsed && (
@@ -459,12 +660,13 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</span>
<button
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={() => openFilesModal()}
onClick={() => navigate("/files")}
title={t(
"fileSidebar.openFileManager",
"Open file manager",
"Browse all files & folders",
)}
type="button"
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
</button>
@@ -476,14 +678,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
>
<AddIcon sx={{ fontSize: "1rem" }} />
</button>
<input
ref={nativeFileInputRef}
type="file"
multiple
accept=".pdf"
style={{ display: "none" }}
onChange={handleNativeFilePick}
/>
</div>
{!stubsLoaded ? (
@@ -497,14 +691,14 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
(id) => (id as string) === (stub.id as string),
);
const isInWorkbench = !!workbenchFileId;
// Both active and viewed-in-viewer are ID-based never index-based.
// Both active and viewed-in-viewer are ID-based - never index-based.
const isViewedInViewer = !!(
viewedWorkbenchId &&
viewedWorkbenchId === (stub.id as string)
);
const isActive = isViewedInViewer;
// In-memory thumbnail may be fresher than the IndexedDB stub.
// Encrypted files never get a raster thumbnail use undefined
// Encrypted files never get a raster thumbnail - use undefined
// so the sidebar icon is shown instead of a stale canvas thumbnail.
const isEncryptedFile =
stub.processedFile?.isEncrypted === true;
@@ -548,44 +742,53 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
{/* Bottom bar: user name + settings */}
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
<Tooltip
label={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
title={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
position="right"
withinPortal
disabled={!collapsed}
>
<div className="file-sidebar-bottom-avatar" title={displayName}>
{displayName.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className="file-sidebar-bottom-avatar"
aria-label={displayName}
>
{displayName.charAt(0).toUpperCase()}
</div>
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</div>
);
},
@@ -63,7 +63,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const navigate = useNavigate();
const location = useLocation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { isFilesModalOpen } = useFilesModalContext();
const {
handleReaderToggle,
handleToolSelect,
@@ -131,7 +131,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
).length;
setPendingSignCount(pending);
} catch {
/* silent avoid noisy background error toasts */
/* silent - avoid noisy background error toasts */
}
};
fetchCount();
@@ -264,7 +264,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
return `${normalized}/share/`;
}
} catch {
// invalid URL fall through to default
// invalid URL - fall through to default
}
}
return absoluteWithBasePath("/share/");
@@ -592,7 +592,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
const handleFilesButtonClick = () => {
openFilesModal();
navigate("/files");
};
// Helper function to render navigation buttons with URL support
@@ -44,7 +44,7 @@
}
.workbench-bar-view-btn:hover {
background-color: var(--bg-hover);
background-color: var(--hover-bg);
color: var(--text-primary);
}
@@ -53,6 +53,20 @@
color: var(--mantine-color-blue-6, #3b82f6);
}
.workbench-bar-view-btn.workbench-bar-back-btn {
color: var(--text-primary);
font-weight: 600;
}
.workbench-bar-view-btn.workbench-bar-back-btn:hover {
background-color: color-mix(
in srgb,
var(--accent-interactive, #4a90e2) 12%,
transparent
);
color: var(--accent-interactive, #4a90e2);
}
.workbench-bar-view-btn svg {
font-size: 16px !important;
width: 16px;
@@ -105,7 +119,7 @@
margin-left: auto;
}
/* Shared action icon style applies to both center and global buttons */
/* Shared action icon style - applies to both center and global buttons */
.workbench-bar-action-icon {
color: var(--text-secondary) !important;
width: 28px !important;
@@ -121,7 +135,7 @@
.workbench-bar-action-icon:hover {
color: var(--text-primary) !important;
background-color: var(--bg-hover) !important;
background-color: var(--hover-bg) !important;
}
.workbench-bar-action-icon[data-variant="filled"] {
@@ -1,6 +1,19 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import {
clearFilesPageReturnRoute,
getFilesPageReturnRoute,
subscribeFilesPageReturnRoute,
} from "@app/components/filesPage/filesPageReturnRoute";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
useFileState,
@@ -66,6 +79,18 @@ export default function WorkbenchBar({
hasFiles,
}: WorkbenchBarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const returnRoute = useSyncExternalStore(
subscribeFilesPageReturnRoute,
getFilesPageReturnRoute,
() => null,
);
const handleBackToFiles = useCallback(() => {
if (!returnRoute) return;
const target = returnRoute.route;
clearFilesPageReturnRoute();
navigate(target);
}, [returnRoute, navigate]);
const { buttons, actions, allButtonsDisabled } = useWorkbenchBar();
const {
pageEditorFunctions,
@@ -356,8 +381,36 @@ export default function WorkbenchBar({
data-wrapped="true"
data-tour="workbench-bar"
>
{/* Left: View switcher */}
{/* Left: optional "Back to My Files" + view switcher */}
<div className="workbench-bar-views" data-tour="view-switcher">
{returnRoute && hasFiles && (
<>
<button
type="button"
className="workbench-bar-view-btn workbench-bar-back-btn"
onClick={handleBackToFiles}
aria-label={t(
returnRoute.label
? "filesPage.backToFolder"
: "filesPage.backToMyFiles",
returnRoute.label
? `Back to ${returnRoute.label}`
: "Back to My Files",
{ folder: returnRoute.label ?? "" },
)}
>
<ArrowBackIcon style={{ fontSize: "1.1rem" }} />
<span className="workbench-bar-view-label">
{returnRoute.label
? t("filesPage.backToFolder", "Back to {{folder}}", {
folder: returnRoute.label,
})
: t("filesPage.backToMyFiles", "Back to My Files")}
</span>
</button>
<div className="workbench-bar-divider" />
</>
)}
{hasFiles &&
viewOptions.map((opt) => (
<button
@@ -373,7 +426,7 @@ export default function WorkbenchBar({
))}
</div>
{/* Tool buttons second row, only rendered when buttons exist */}
{/* Tool buttons - second row, only rendered when buttons exist */}
{sectionsWithButtons.length > 0 && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
@@ -395,7 +448,7 @@ export default function WorkbenchBar({
</div>
)}
{/* Right: Global buttons export group left, close anchored right */}
{/* Right: Global buttons - export group left, close anchored right */}
<div className="workbench-bar-globals">
{/* Print */}
{currentView === "viewer" &&
@@ -241,6 +241,8 @@ function FileContextInner({
insertAfterPageId?: string;
selectFiles?: boolean;
skipAutoUnzip?: boolean;
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -0,0 +1,559 @@
/** Shared state for the My Files view. */
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import { fileStorage } from "@app/services/fileStorage";
import { folderSyncService } from "@app/services/folderSyncService";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { reconcileServerFiles } from "@app/services/fileSyncService";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
export type FilesPageSortMode =
| "name-asc"
| "name-desc"
| "modified-desc"
| "modified-asc"
| "size-desc"
| "size-asc";
export type FilesPageOriginFilter =
| "all"
| "local"
| "cloud"
| "shared-with-me";
/** all|local|cloud|recent|shared filter presets. */
export type FilesPageTab =
| "all"
| "local"
| "cloud"
| "recent"
| "shared"
| "sharedByMe"
| "imSharing";
export interface FolderNameDialogState {
mode: "new" | "rename" | null;
parentId?: FolderId | null;
folder?: FolderRecord;
}
export interface MoveDialogState {
open: boolean;
fileIds?: FileId[];
folderId?: FolderId;
initial: FolderId | null;
}
interface FilesPageContextValue {
// Cached files (leaf-only)
allFiles: StirlingFileStub[];
fileMap: Map<FileId, StirlingFileStub>;
fileCountsByFolder: Map<FolderId | null, number>;
loading: boolean;
refresh: () => Promise<void>;
// Selection
selectedFileIds: Set<FileId>;
setSelectedFileIds: React.Dispatch<React.SetStateAction<Set<FileId>>>;
clearSelection: () => void;
// View + sort + search + filters
viewMode: FilesPageViewMode;
setViewMode: (mode: FilesPageViewMode) => void;
sortMode: FilesPageSortMode;
setSortMode: (mode: FilesPageSortMode) => void;
search: string;
setSearch: (value: string) => void;
originFilter: FilesPageOriginFilter;
setOriginFilter: (filter: FilesPageOriginFilter) => void;
/** Selected file extensions (uppercased, e.g. ["PDF", "DOCX"]).
* Empty array = no type filter applied. */
typeFilter: string[];
setTypeFilter: (next: string[]) => void;
/** Active filter-tab. Drives which files appear and which UI affordances enable. */
currentTab: FilesPageTab;
setCurrentTab: (tab: FilesPageTab) => void;
// Dialog state
folderNameDialog: FolderNameDialogState;
openNewFolderDialog: (parentId?: FolderId | null) => void;
openRenameFolderDialog: (folder: FolderRecord) => void;
closeFolderNameDialog: () => void;
submitFolderName: (name: string) => Promise<void>;
moveDialog: MoveDialogState;
promptMoveFiles: (fileIds: FileId[]) => void;
closeMoveDialog: () => void;
// Action helpers
moveFilesTo: (fileIds: FileId[], folderId: FolderId | null) => Promise<void>;
moveFolderTo: (
folderId: FolderId,
newParentId: FolderId | null,
) => Promise<void>;
removeFiles: (fileIds: FileId[]) => Promise<void>;
/** Open the confirmation dialog; consumer renders DeleteFolderDialog. */
promptDeleteFolder: (folder: FolderRecord) => void;
/** Confirmed delete; pass deleteContents=true to also remove files inside. */
deleteFolder: (
folder: FolderRecord,
deleteContents: boolean,
) => Promise<void>;
deleteFolderDialog: {
folder: FolderRecord | null;
fileCount: number;
};
closeDeleteFolderDialog: () => void;
setFolderAppearance: (
folderId: FolderId,
appearance: { color?: string; icon?: string | null },
) => Promise<void>;
}
const FilesPageContext = createContext<FilesPageContextValue | null>(null);
export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const indexedDB = useIndexedDB();
const folders = useFolders();
const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
const [loading, setLoading] = useState(true);
// Generation counter to drop stale reconcile results when a second refresh
// overlaps the first. Mirrors the pattern FolderContext.pullFromServer uses
// via pullInFlight, but kept as a counter so we can also discard results
// from clearly-out-of-date calls instead of just serializing them.
const refreshGenRef = useRef(0);
// Narrow dep so refresh isn't recreated on every folders field change.
const setFoldersError = folders.setError;
const storageEnabled = appConfig?.storageEnabled === true;
const shareLinksEnabled = appConfig?.storageShareLinksEnabled === true;
const refresh = useCallback(async () => {
const gen = ++refreshGenRef.current;
setLoading(true);
try {
const localStubs = await fileStorage.getAllStirlingFileStubs();
// Bail if a newer refresh started while IDB was reading.
if (gen !== refreshGenRef.current) return;
const localLeaf = localStubs.filter((s) => s.isLeaf !== false);
// Render the cache immediately while the server fetch is in flight.
setAllFiles(localLeaf);
const merged = await reconcileServerFiles(localLeaf, {
storageEnabled,
shareLinksEnabled,
});
// Drop the merged result if a newer refresh has already started -
// otherwise its stale snapshot will clobber the newer one's state.
if (gen !== refreshGenRef.current) return;
setAllFiles(merged);
} catch (err) {
if (gen !== refreshGenRef.current) return;
console.error("[FilesPageContext] refresh failed", err);
setFoldersError(
err instanceof Error ? err.message : "Failed to load files",
);
} finally {
// Only the latest refresh should clear the loading state.
if (gen === refreshGenRef.current) setLoading(false);
}
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
useEffect(() => {
void refresh();
}, [refresh, indexedDB.revision]);
const fileMap = useMemo(() => {
const map = new Map<FileId, StirlingFileStub>();
for (const f of allFiles) map.set(f.id, f);
return map;
}, [allFiles]);
const fileCountsByFolder = useMemo(() => {
const map = new Map<FolderId | null, number>();
map.set(ROOT_FOLDER_ID, 0);
for (const f of folders.folders) map.set(f.id, 0);
for (const file of allFiles) {
const fid = file.folderId ?? null;
map.set(fid, (map.get(fid) ?? 0) + 1);
}
return map;
}, [allFiles, folders.folders]);
// Selection ---------------------------------------------------------------
const [selectedFileIds, setSelectedFileIds] = useState<Set<FileId>>(
() => new Set(),
);
const clearSelection = useCallback(() => setSelectedFileIds(new Set()), []);
// Clear selection when folder changes.
useEffect(() => {
clearSelection();
}, [folders.currentFolderId, clearSelection]);
// View + sort + search + filters ----------------------------------------
const [viewMode, setViewMode] = useState<FilesPageViewMode>("grid");
const [sortMode, setSortMode] = useState<FilesPageSortMode>("modified-desc");
const [search, setSearch] = useState("");
const [originFilter, setOriginFilter] =
useState<FilesPageOriginFilter>("all");
const [typeFilter, setTypeFilter] = useState<string[]>([]);
const [currentTab, setCurrentTab] = useState<FilesPageTab>("all");
// Dialog: folder name -----------------------------------------------------
const [folderNameDialog, setFolderNameDialog] =
useState<FolderNameDialogState>({ mode: null });
const openNewFolderDialog = useCallback(
(parentId: FolderId | null = folders.currentFolderId) => {
setFolderNameDialog({ mode: "new", parentId });
},
[folders.currentFolderId],
);
const openRenameFolderDialog = useCallback((folder: FolderRecord) => {
setFolderNameDialog({ mode: "rename", folder });
}, []);
const closeFolderNameDialog = useCallback(() => {
setFolderNameDialog({ mode: null });
}, []);
const submitFolderName = useCallback(
async (name: string) => {
if (folderNameDialog.mode === "new") {
await folders.createFolder(
name,
folderNameDialog.parentId ?? folders.currentFolderId,
);
} else if (
folderNameDialog.mode === "rename" &&
folderNameDialog.folder
) {
await folders.renameFolder(folderNameDialog.folder.id, name);
}
},
[folderNameDialog, folders],
);
// Dialog: move ------------------------------------------------------------
const [moveDialog, setMoveDialog] = useState<MoveDialogState>({
open: false,
initial: ROOT_FOLDER_ID,
});
const promptMoveFiles = useCallback(
(fileIds: FileId[]) => {
setMoveDialog({ open: true, fileIds, initial: folders.currentFolderId });
},
[folders.currentFolderId],
);
const closeMoveDialog = useCallback(() => {
setMoveDialog((m) => ({ ...m, open: false }));
}, []);
// Action helpers ----------------------------------------------------------
/** Cloud files move server-first; local files auto-upload then move. */
const moveFilesTo = useCallback(
async (fileIds: FileId[], folderId: FolderId | null) => {
if (fileIds.length === 0) return;
const stubs = fileIds
.map((id) => fileMap.get(id))
.filter((s): s is StirlingFileStub => Boolean(s));
const localOnly = stubs.filter((s) => s.remoteStorageId == null);
// Cloud list is mutated below with newly-promoted local files.
const cloudFiles = stubs.filter((s) => s.remoteStorageId != null);
if (folderId !== null && localOnly.length > 0) {
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
try {
for (const stub of localOnly) {
const rootId = (stub.originalFileId || stub.id) as FileId;
const { remoteId, updatedAt, chain } =
await uploadHistoryChain(rootId);
for (const chainStub of chain) {
fileActions.updateStirlingFileStub(chainStub.id, {
remoteStorageId: remoteId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
});
await fileStorage.updateFileMetadata(chainStub.id, {
remoteStorageId: remoteId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
});
}
// Promoted file joins the bulk-move round.
cloudFiles.push({
...stub,
remoteStorageId: remoteId,
remoteStorageUpdatedAt: updatedAt,
remoteOwnedByCurrentUser: true,
remoteSharedViaLink: false,
});
}
} catch (err) {
folders.setError(
err instanceof Error
? `Could not save files to server: ${err.message}`
: "Could not save files to server.",
);
throw err;
}
}
if (cloudFiles.length > 0) {
try {
const remoteIds = cloudFiles
.map((s) => s.remoteStorageId!)
.filter((id): id is number => typeof id === "number");
const result = await folderSyncService.bulkMoveFiles(
remoteIds,
folderId,
);
if (result.skippedFileIds.length > 0) {
folders.setError(
t(
"filesPage.moveSkippedRemote",
"{{count}} file(s) couldn't be moved on the server (no permission or already deleted).",
{ count: result.skippedFileIds.length },
),
);
}
const movedRemoteSet = new Set(result.movedFileIds);
const idsToCacheMove = cloudFiles
.filter((s) => movedRemoteSet.has(s.remoteStorageId!))
.map((s) => s.id);
if (idsToCacheMove.length > 0) {
await indexedDB.moveFilesToFolder(idsToCacheMove, folderId);
}
} catch (err) {
folders.setError(
err instanceof Error
? `Could not move files: ${err.message}`
: "Could not move files.",
);
throw err;
}
}
// Local files moving to ROOT need no cloud write.
await refresh();
},
[indexedDB, refresh, fileMap, folders, t, fileActions],
);
const moveFolderTo = useCallback(
async (folderId: FolderId, newParentId: FolderId | null) => {
// Client-side cycle guard.
if (newParentId !== null && folders.isDescendant(newParentId, folderId)) {
folders.setError(
t(
"filesPage.cycleBlocked",
"Can't move a folder into one of its own subfolders.",
),
);
return;
}
await folders.moveFolder(folderId, newParentId);
},
[folders, t],
);
const removeFiles = useCallback(
async (fileIds: FileId[]) => {
if (fileIds.length === 0) return;
const ok = window.confirm(
t(
"filesPage.removeConfirm",
"Delete {{count}} file(s)? This cannot be undone.",
{ count: fileIds.length },
),
);
if (!ok) return;
await fileActions.removeFiles(fileIds, true);
setSelectedFileIds((prev) => {
const next = new Set(prev);
for (const id of fileIds) next.delete(id);
return next;
});
await refresh();
},
[fileActions, refresh, t],
);
const setFolderAppearance = useCallback(
async (
folderId: FolderId,
appearance: { color?: string; icon?: string | null },
) => {
await folders.updateFolderAppearance(folderId, appearance);
},
[folders],
);
const [deleteFolderDialog, setDeleteFolderDialog] = useState<{
folder: FolderRecord | null;
fileCount: number;
}>({ folder: null, fileCount: 0 });
const closeDeleteFolderDialog = useCallback(
() => setDeleteFolderDialog({ folder: null, fileCount: 0 }),
[],
);
const filesInSubtree = useCallback(
(folderId: FolderId): FileId[] => {
const subtreeIds = new Set<FolderId>([folderId]);
const stack: FolderId[] = [folderId];
while (stack.length > 0) {
const cur = stack.pop()!;
for (const childId of folders.getChildFolderIds(cur)) {
if (subtreeIds.has(childId)) continue;
subtreeIds.add(childId);
stack.push(childId);
}
}
return allFiles
.filter((f) => {
const fid = f.folderId ?? null;
return fid !== null && subtreeIds.has(fid);
})
.map((f) => f.id);
},
[allFiles, folders],
);
const promptDeleteFolder = useCallback(
(folder: FolderRecord) => {
const fileCount = filesInSubtree(folder.id).length;
setDeleteFolderDialog({ folder, fileCount });
},
[filesInSubtree],
);
const deleteFolder = useCallback(
async (folder: FolderRecord, deleteContents: boolean) => {
if (deleteContents) {
const fileIds = filesInSubtree(folder.id);
if (fileIds.length > 0) {
await fileActions.removeFiles(fileIds, true);
}
}
await folders.deleteFolder(folder.id);
await refresh();
},
[fileActions, filesInSubtree, folders, refresh],
);
// Memoise to avoid re-rendering every FileCard on unrelated state churn.
const value = useMemo<FilesPageContextValue>(
() => ({
allFiles,
fileMap,
fileCountsByFolder,
loading,
refresh,
selectedFileIds,
setSelectedFileIds,
clearSelection,
viewMode,
setViewMode,
sortMode,
setSortMode,
search,
setSearch,
originFilter,
setOriginFilter,
typeFilter,
setTypeFilter,
currentTab,
setCurrentTab,
folderNameDialog,
openNewFolderDialog,
openRenameFolderDialog,
closeFolderNameDialog,
submitFolderName,
moveDialog,
promptMoveFiles,
closeMoveDialog,
moveFilesTo,
moveFolderTo,
removeFiles,
promptDeleteFolder,
deleteFolder,
deleteFolderDialog,
closeDeleteFolderDialog,
setFolderAppearance,
}),
[
allFiles,
fileMap,
fileCountsByFolder,
loading,
refresh,
selectedFileIds,
clearSelection,
viewMode,
sortMode,
search,
originFilter,
typeFilter,
currentTab,
folderNameDialog,
openNewFolderDialog,
openRenameFolderDialog,
closeFolderNameDialog,
submitFolderName,
moveDialog,
promptMoveFiles,
closeMoveDialog,
moveFilesTo,
moveFolderTo,
removeFiles,
promptDeleteFolder,
deleteFolder,
deleteFolderDialog,
closeDeleteFolderDialog,
setFolderAppearance,
],
);
return (
<FilesPageContext.Provider value={value}>
{children}
</FilesPageContext.Provider>
);
}
export function useFilesPage(): FilesPageContextValue {
const ctx = useContext(FilesPageContext);
if (!ctx) {
throw new Error("useFilesPage must be used within a FilesPageProvider");
}
return ctx;
}
@@ -0,0 +1,154 @@
import React from "react";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
/**
* Regression test for the sync-banner 4xx gating fix in commit c38b646c5.
* Pre-fix, `pullFromServer.catch` unconditionally called `setError(...)`,
* surfacing a "Folder sync failed: ..." banner for 401/403 responses
* (storage disabled, user not signed in) - noise the user can't act on
* from inside the file manager.
*
* Post-fix, the banner only appears when `status === undefined || status >= 500`
* (server-side outage or genuine network failure - both retryable).
*
* The gate's `status` extraction relies on axios's error shape
* (`err.response.status`). A future HTTP-client swap (e.g. to fetch where
* the shape is `err.status`) would silently make `status` always undefined
* and re-enable the noisy banner for every 4xx - exactly the regression
* this test pins down.
*/
// Mock the services FolderContext depends on. The real implementations
// require a running backend (folderSyncService) and a populated IDB
// (folderStorage). Both are out of scope for testing the error gate.
const mockList = vi.fn();
vi.mock("@app/services/folderSyncService", () => ({
folderSyncService: {
list: () => mockList(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock("@app/services/folderStorage", () => ({
folderStorage: {
getAllFolders: vi.fn().mockResolvedValue([]),
replaceAll: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("@app/contexts/IndexedDBContext", () => ({
useIndexedDB: () => ({
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
}),
}));
// FolderProvider short-circuits pullFromServer when AppConfig says storage
// is off. These tests are specifically about what happens when the pull
// DOES fire and rejects, so we mock `storageEnabled = true`.
vi.mock("@app/contexts/AppConfigContext", () => ({
useAppConfig: () => ({
config: { storageEnabled: true, storageSharingEnabled: false },
}),
}));
/**
* Probe consumer that surfaces the bits of `useFolders()` this test cares
* about. Rendered as text nodes so RTL queries can wait on them.
*/
function Probe() {
const { error, serverReachable } = useFolders();
return (
<>
<div data-testid="error">{error ?? "<null>"}</div>
<div data-testid="reachable">{String(serverReachable)}</div>
</>
);
}
/**
* Build an axios-shape rejection. `pullFromServer` reads
* `err.response.status` to classify.
*/
function axiosError(status: number, message = "rejected"): Error {
const err = new Error(message) as Error & {
response: { status: number; data: unknown };
};
err.response = { status, data: { message } };
return err;
}
async function renderAndWaitForPull(): Promise<void> {
render(
<FolderProvider>
<Probe />
</FolderProvider>,
);
// The pull is fired from a mount effect; wait until it has resolved by
// observing that `mockList` was called at least once and a tick has
// elapsed for the resulting setState to flush.
await waitFor(() => expect(mockList).toHaveBeenCalled());
// Flush microtasks so the catch-block's setState lands.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("FolderContext sync-banner gating", () => {
beforeEach(() => {
mockList.mockReset();
});
test("401 (unauthorized) does NOT surface a banner", async () => {
mockList.mockRejectedValue(axiosError(401, "unauthorized"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toBe("<null>");
// Buttons still need to be disabled - this is intentional.
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("403 (storage disabled) does NOT surface a banner", async () => {
mockList.mockRejectedValue(axiosError(403, "Storage is disabled"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toBe("<null>");
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("500 (server error) DOES surface a banner", async () => {
mockList.mockRejectedValue(axiosError(500, "internal"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toContain(
"Folder sync failed",
);
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("network error (no response) DOES surface a banner", async () => {
// axios on a network failure rejects with an Error that has NO
// `.response` property - that's the "status === undefined" branch
// the gate explicitly covers.
mockList.mockRejectedValue(new Error("ECONNREFUSED"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toContain(
"Folder sync failed",
);
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("404 (endpoint missing, core-only build) does NOT surface a banner", async () => {
// Separate code path from the 4xx gate - 404 specifically means
// the storage backend isn't deployed; it's a config signal, not a
// failure to act on.
mockList.mockRejectedValue(axiosError(404, "not found"));
await renderAndWaitForPull();
expect(screen.getByTestId("error").textContent).toBe("<null>");
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
});
@@ -0,0 +1,645 @@
/**
* FolderContext - state management for the cloud folder hierarchy.
*
* The server is the source of truth. This context maintains an in-memory copy
* (sourced from a small IDB read-cache for instant first paint), and every
* mutation goes server-first via `folderSyncService`; on success the local
* cache is updated and a revision tick fires so consumers re-render.
*
* The local IDB cache only exists so:
* 1. The tree paints immediately on mount without waiting for the API.
* 2. We can show a read-only tree when the server is unreachable.
*
* Cycle detection, ownership checks, and the per-user folder cap all live
* on the server. The client just surfaces 400/409 errors via the existing
* dialog Alert pattern.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { folderStorage } from "@app/services/folderStorage";
import { folderSyncService } from "@app/services/folderSyncService";
import {
FolderBreadcrumbEntry,
FolderId,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
createFolderId,
pickFolderColor,
} from "@app/types/folder";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
interface FolderContextValue {
folders: FolderRecord[];
foldersById: Map<FolderId, FolderRecord>;
tree: FolderTreeNode[];
loading: boolean;
error: string | null;
/** Push a user-visible error string to the banner; null clears it. */
setError: (msg: string | null) => void;
/**
* Whether the most recent folder-API round-trip succeeded. Used to gate
* folder mutation controls - when false, "New folder", rename, move,
* delete, and appearance picker should be disabled.
*
* Initial value is `false` (fail-closed). Consumers will see a brief
* window of disabled controls during the first pullFromServer call;
* starting `true` would cause the inverse flash (enabled controls that
* the user can click before the first response arrives, then a banner
* after they submit the dialog), which is the worse failure mode.
*
* Naming caveat: the flag flips false for ANY failure mode, not just
* literal network unreachability. A 401 (not signed in) or 403 (storage
* disabled on the server) also flips it false - in both cases the user
* cannot mutate server-side folders, so the disabled UX is correct, but
* the tooltip wording should avoid claiming the user is "offline"
* (they may not be). The shared i18n key `filesPage.offlineNoFolderEdits`
* has been worded to cover all three cases.
*/
serverReachable: boolean;
currentFolderId: FolderId | null;
setCurrentFolderId: (id: FolderId | null) => void;
breadcrumbs: FolderBreadcrumbEntry[];
/** Re-read the IDB cache (cheap; for revision-driven refresh). */
refresh: () => Promise<void>;
/**
* Fetch from server, replace the local cache, and bump the revision.
* Returns the result so the UI can distinguish "endpoint not deployed"
* from real failures.
*/
pullFromServer: () => Promise<{
ok: boolean;
reason?: "endpoint-missing" | "network" | "server" | "client";
}>;
createFolder: (
name: string,
parentFolderId?: FolderId | null,
) => Promise<FolderRecord>;
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
moveFolder: (
id: FolderId,
newParentId: FolderId | null,
) => Promise<FolderRecord | null>;
updateFolderAppearance: (
id: FolderId,
appearance: { color?: string; icon?: string | null },
) => Promise<FolderRecord | null>;
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
getChildFolderIds: (parentId: FolderId | null) => FolderId[];
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
}
const FolderContext = createContext<FolderContextValue | null>(null);
interface FolderProviderProps {
children: React.ReactNode;
}
function buildTree(folders: FolderRecord[]): FolderTreeNode[] {
const byParent = new Map<FolderId | null, FolderRecord[]>();
for (const folder of folders) {
const list = byParent.get(folder.parentFolderId) ?? [];
list.push(folder);
byParent.set(folder.parentFolderId, list);
}
for (const list of byParent.values()) {
list.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}
// Cap recursion. Backend cycle detection + per-user folder cap already
// prevent legitimately-deep chains, so any chain past this limit means
// either a corrupted local cache or a malicious server response. Bail
// out at the depth boundary so the JS call stack stays finite either
// way; the user just sees the tree truncated at the bad node.
const MAX_BUILD_DEPTH = 50;
const build = (
parentId: FolderId | null,
depth: number,
): FolderTreeNode[] => {
if (depth >= MAX_BUILD_DEPTH) return [];
const direct = byParent.get(parentId) ?? [];
return direct.map((folder) => ({
folder,
depth,
children: build(folder.id, depth + 1),
}));
};
return build(ROOT_FOLDER_ID, 0);
}
/** Convert a server-side error to a banner-ready user message. */
function formatServerError(err: unknown): string {
if (err && typeof err === "object" && "response" in err) {
const response = (
err as {
response?: {
status?: number;
data?: {
message?: string;
error?: string;
errors?: { defaultMessage?: string }[];
};
};
}
).response;
const status = response?.status;
const msg =
response?.data?.message ??
response?.data?.error ??
response?.data?.errors?.[0]?.defaultMessage;
if (msg) return status ? `${msg} (HTTP ${status})` : msg;
}
return err instanceof Error ? err.message : "Folder operation failed";
}
/**
* Classify an error's reachability signal. 5xx and "no status" (network) mean
* the server is *not* reachable; 4xx means the server answered (just rejected
* our request). Used to update {@link serverReachable} from mutation paths.
*/
function reachabilityFromError(err: unknown): boolean {
const status = (err as { response?: { status?: number } })?.response?.status;
return status !== undefined && status >= 400 && status < 500;
}
/**
* True if `currentId` or any of its ancestors is in `removedSet`. Walks up via
* `parentFolderId` using the pre-removal `folders` snapshot so the chain is
* still discoverable while the cascade is in flight. Used to force-reset
* currentFolderId when the user was browsing a folder whose ancestor just
* got deleted (otherwise the UI strands them on a folder id that no longer
* exists on the server).
*/
function shouldStrandedReset(
currentId: FolderId,
removedSet: Set<FolderId>,
folders: FolderRecord[],
): boolean {
const byId = new Map(folders.map((f) => [f.id, f]));
let cursor: FolderId | null = currentId;
// Bounded walk: max 50 levels matches the existing depth guard elsewhere
// and protects against malformed parent cycles.
for (let i = 0; i < 50 && cursor; i++) {
if (removedSet.has(cursor)) return true;
const node = byId.get(cursor);
cursor = (node?.parentFolderId ?? null) as FolderId | null;
}
return false;
}
export function FolderProvider({ children }: FolderProviderProps) {
const [folders, setFolders] = useState<FolderRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Start `false` so folder-mutation buttons are disabled until the first
// pullFromServer proves the backend is up. Starting `true` would let users
// click "New folder" during the gap, then see a banner error after they
// submitted the dialog.
const [serverReachable, setServerReachable] = useState(false);
const [currentFolderId, setCurrentFolderId] = useState<FolderId | null>(
ROOT_FOLDER_ID,
);
// Tick when folder state changes so consumers re-read.
const [folderRevision, setFolderRevision] = useState(0);
const bumpFolderRevision = useCallback(() => {
setFolderRevision((r) => r + 1);
}, []);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
setLoading(true);
try {
const all = await folderStorage.getAllFolders();
if (!mountedRef.current) return;
setFolders(all);
} catch (err) {
console.error("[FolderContext] cache read failed", err);
if (mountedRef.current) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh, folderRevision]);
// Single-flight guard - concurrent pullFromServer calls would race and the
// last-resolving (often older) response would clobber the newer state.
type PullResult = {
ok: boolean;
reason?: "endpoint-missing" | "network" | "server" | "client";
};
const pullInFlight = useRef<Promise<PullResult> | null>(null);
const pullFromServer = useCallback(async (): Promise<PullResult> => {
if (pullInFlight.current) return pullInFlight.current;
const promise: Promise<PullResult> = (async () => {
let remote: FolderRecord[];
try {
remote = await folderSyncService.list();
} catch (err) {
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status === 404) {
// Storage backend not deployed in this build - expected for
// core-only.
if (mountedRef.current) setServerReachable(false);
return { ok: false, reason: "endpoint-missing" };
}
console.warn("[FolderContext] pullFromServer failed", err);
if (mountedRef.current) {
setServerReachable(false);
// Only surface a banner when this is a server-side outage or
// network glitch the user can act on. 4xx responses are
// configuration / auth signals - the deployment chose to disable
// storage (403 "Storage is disabled") or the user simply isn't
// logged in yet (401) - in both cases the "Folder sync failed"
// banner is noise the user can't fix from inside the file
// manager. Folder-mutation buttons get individual disabled
// tooltips via `serverReachable`, which is enough signal.
if (status === undefined || status >= 500) {
setError(`Folder sync failed: ${formatServerError(err)}`);
}
}
// Narrowing the ternary into a typed variable so TS keeps the literal
// union rather than widening to `string`.
const reason: PullResult["reason"] =
status && status >= 500 ? "server" : status ? "client" : "network";
return { ok: false, reason };
}
// Server-wins: cache becomes a verbatim copy of what the server
// returned. A folder absent from the response was deleted server-side;
// replaceAll drops it from the cache too.
try {
await folderStorage.replaceAll(remote);
} catch (cacheErr) {
// The server response was good; only the local cache write failed.
// We still consider this an ok pull - render from in-memory state.
console.warn("[FolderContext] cache replace failed", cacheErr);
}
if (mountedRef.current) {
setFolders(remote);
setServerReachable(true);
setError(null);
}
bumpFolderRevision();
return { ok: true };
})();
pullInFlight.current = promise;
try {
return await promise;
} finally {
pullInFlight.current = null;
}
}, [bumpFolderRevision]);
// Pull from server on mount - the IDB cache is for instant paint only;
// we always reconcile with the server before letting the user mutate.
// Short-circuit when AppConfig already tells us storage is off (desktop,
// login disabled, or `storage.enabled=false`). The server-side
// ConfigController computes `storageEnabled = enableLogin && storage.isEnabled`
// so this single check covers all three failure modes - saves one
// guaranteed-to-403 round-trip per session.
const { config: appConfig } = useAppConfig();
const storageBackedByServer = appConfig?.storageEnabled === true;
useEffect(() => {
if (!storageBackedByServer) return;
void pullFromServer();
}, [pullFromServer, storageBackedByServer]);
const foldersById = useMemo(() => {
const map = new Map<FolderId, FolderRecord>();
for (const folder of folders) map.set(folder.id, folder);
return map;
}, [folders]);
const tree = useMemo(() => buildTree(folders), [folders]);
const breadcrumbs = useMemo<FolderBreadcrumbEntry[]>(() => {
// Root name is a placeholder - consumers should detect
// `entry.id === ROOT_FOLDER_ID` and substitute their own translated label.
const path: FolderBreadcrumbEntry[] = [
{ id: ROOT_FOLDER_ID, name: "All files" },
];
if (currentFolderId === null) return path;
const chain: FolderRecord[] = [];
let cursor: FolderId | null = currentFolderId;
const seen = new Set<FolderId>();
while (cursor !== null) {
if (seen.has(cursor)) break;
seen.add(cursor);
const folder = foldersById.get(cursor);
if (!folder) break;
chain.unshift(folder);
cursor = folder.parentFolderId;
}
for (const folder of chain) {
path.push({ id: folder.id, name: folder.name });
}
return path;
}, [currentFolderId, foldersById]);
const getChildFolderIds = useCallback(
(parentId: FolderId | null): FolderId[] =>
folders.filter((f) => f.parentFolderId === parentId).map((f) => f.id),
[folders],
);
const isDescendant = useCallback(
(candidateId: FolderId, ancestorId: FolderId | null): boolean => {
if (ancestorId === null) return true;
let cursor: FolderId | null = candidateId;
const seen = new Set<FolderId>();
while (cursor !== null) {
if (cursor === ancestorId) return true;
if (seen.has(cursor)) return false;
seen.add(cursor);
cursor = foldersById.get(cursor)?.parentFolderId ?? null;
}
return false;
},
[foldersById],
);
// ─── mutations: server-first, cache update on success ──────────────
/**
* Centralised mutation wrapper:
* 1. Calls the server op.
* 2. On success: flips `serverReachable=true`, updates in-memory state
* from the server response, then best-effort writes the cache (cache
* failure does NOT roll back; the in-memory truth came from the server).
* 3. On failure: updates `serverReachable` per the error class, surfaces
* via {@link setError}, re-throws so the caller's dialog can stay open.
*/
const runFolderMutation = useCallback(
async <T,>(
serverOp: () => Promise<T>,
onSuccess: (result: T) => void | Promise<void>,
): Promise<T> => {
let result: T;
try {
result = await serverOp();
} catch (err) {
if (mountedRef.current) {
setServerReachable(reachabilityFromError(err));
setError(formatServerError(err));
}
throw err;
}
if (mountedRef.current) {
setServerReachable(true);
setError(null);
}
try {
await onSuccess(result);
} catch (cacheErr) {
// The server is authoritative - a cache write failure must not be
// surfaced as if the operation failed. Log + leave the in-memory
// state authoritative; next pullFromServer will re-seed the cache.
console.warn(
"[FolderContext] cache update failed after successful mutation",
cacheErr,
);
}
bumpFolderRevision();
return result;
},
[bumpFolderRevision],
);
const createFolder = useCallback(
async (
name: string,
parentFolderId: FolderId | null = currentFolderId,
): Promise<FolderRecord> => {
const color = pickFolderColor(name);
// Generate id client-side so the server's idempotency check makes
// retries safe (network blip → second POST returns the same row).
const id = createFolderId();
return runFolderMutation(
() =>
folderSyncService.create({
id,
name,
parentFolderId,
color,
}),
async (record) => {
setFolders((prev) => [
...prev.filter((f) => f.id !== record.id),
record,
]);
await folderStorage.upsertFolder(record);
},
);
},
[currentFolderId, runFolderMutation],
);
const renameFolder = useCallback(
async (id: FolderId, name: string) => {
return runFolderMutation(
() => folderSyncService.update(id, { name }),
async (record) => {
setFolders((prev) =>
prev.map((f) => (f.id === record.id ? record : f)),
);
await folderStorage.upsertFolder(record);
},
);
},
[runFolderMutation],
);
const moveFolder = useCallback(
async (id: FolderId, newParentId: FolderId | null) => {
return runFolderMutation(
() =>
folderSyncService.update(id, {
reparent: true,
parentFolderId: newParentId,
}),
async (record) => {
setFolders((prev) =>
prev.map((f) => (f.id === record.id ? record : f)),
);
await folderStorage.upsertFolder(record);
},
);
},
[runFolderMutation],
);
const updateFolderAppearance = useCallback(
async (
id: FolderId,
appearance: { color?: string; icon?: string | null },
) => {
return runFolderMutation(
() =>
folderSyncService.update(id, {
color: appearance.color,
icon: appearance.icon,
}),
async (record) => {
setFolders((prev) =>
prev.map((f) => (f.id === record.id ? record : f)),
);
await folderStorage.upsertFolder(record);
},
);
},
[runFolderMutation],
);
const { clearFolderForFiles } = useIndexedDB();
const deleteFolder = useCallback(
async (id: FolderId): Promise<FolderId[]> => {
// Custom path (not runFolderMutation) because we have two best-effort
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
// cleanups so the user isn't stranded inside a tombstone if the cache
// write fails.
let removed: FolderId[];
try {
removed = await folderSyncService.delete(id);
} catch (err) {
if (mountedRef.current) {
setServerReachable(reachabilityFromError(err));
setError(formatServerError(err));
}
throw err;
}
const removedSet = new Set(removed);
if (mountedRef.current) {
setServerReachable(true);
setError(null);
setFolders((prev) => prev.filter((f) => !removedSet.has(f.id)));
// Reset if EITHER the current folder OR any ancestor was deleted.
// Walking by parentFolderId catches the "user is browsing /a/b/c and
// we just deleted /a" case where the exact-id check would leave the
// UI pointing at /c which no longer exists.
if (
currentFolderId &&
shouldStrandedReset(currentFolderId, removedSet, folders)
) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
bumpFolderRevision();
// Belt-and-braces local cleanup; either failure shouldn't undo the
// server delete or block the second cleanup.
const [cacheResult, filesResult] = await Promise.allSettled([
folderStorage.removeFolders(removed),
clearFolderForFiles(removed),
]);
if (cacheResult.status === "rejected") {
console.warn(
"[FolderContext] folder cache cleanup failed after server delete",
cacheResult.reason,
);
}
if (filesResult.status === "rejected") {
console.warn(
"[FolderContext] file folderId cleanup failed after server delete",
filesResult.reason,
);
if (mountedRef.current) {
setError(
"Folder was deleted, but some files couldn't be detached locally. Refresh to fix.",
);
}
}
return removed;
},
[bumpFolderRevision, clearFolderForFiles, currentFolderId, folders],
);
const value = useMemo<FolderContextValue>(
() => ({
folders,
foldersById,
tree,
loading,
error,
setError,
serverReachable,
currentFolderId,
setCurrentFolderId,
breadcrumbs,
refresh,
pullFromServer,
createFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
deleteFolder,
getChildFolderIds,
isDescendant,
}),
[
folders,
foldersById,
tree,
loading,
error,
serverReachable,
currentFolderId,
breadcrumbs,
refresh,
pullFromServer,
createFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
deleteFolder,
getChildFolderIds,
isDescendant,
],
);
return (
<FolderContext.Provider value={value}>{children}</FolderContext.Provider>
);
}
export function useFolders(): FolderContextValue {
const ctx = useContext(FolderContext);
if (!ctx) {
throw new Error("useFolders must be used within a FolderProvider");
}
return ctx;
}
/** Optional version - returns null when used outside the provider. */
export function useOptionalFolders(): FolderContextValue | null {
return useContext(FolderContext);
}
@@ -7,11 +7,13 @@ import React, {
createContext,
useContext,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { fileStorage } from "@app/services/fileStorage";
import { FileId } from "@app/types/file";
import { FolderId } from "@app/types/folder";
import {
StirlingFileStub,
createStirlingFile,
@@ -21,6 +23,9 @@ import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
const DEBUG = process.env.NODE_ENV === "development";
/** LRU cap for the in-memory file blob cache. Module-scoped so it isn't recreated each render. */
const MAX_CACHE_SIZE = 50;
interface IndexedDBContextValue {
// Core CRUD operations
saveFile: (
@@ -47,7 +52,14 @@ interface IndexedDBContextValue {
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
// Incremented after any write or delete — subscribe to trigger re-reads
// Folder operations
moveFilesToFolder: (
fileIds: FileId[],
folderId: FolderId | null,
) => Promise<FileId[]>;
clearFolderForFiles: (folderIds: FolderId[]) => Promise<number>;
// Incremented after any write or delete - subscribe to trigger re-reads
revision: number;
bumpRevision: () => void;
}
@@ -66,7 +78,6 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const fileCache = useRef(
new Map<FileId, { file: File; lastAccessed: number }>(),
);
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
// LRU cache management
const evictLRUEntries = useCallback(() => {
@@ -242,21 +253,62 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
[bumpRevision],
);
const value: IndexedDBContextValue = {
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
loadLeafMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail,
markFileAsProcessed,
revision,
bumpRevision,
};
const moveFilesToFolder = useCallback(
async (fileIds: FileId[], folderId: FolderId | null): Promise<FileId[]> => {
const updated = await fileStorage.moveFilesToFolder(fileIds, folderId);
if (updated.length > 0) bumpRevision();
return updated;
},
[bumpRevision],
);
const clearFolderForFiles = useCallback(
async (folderIds: FolderId[]): Promise<number> => {
const cleared = await fileStorage.clearFolderForFiles(folderIds);
if (cleared > 0) bumpRevision();
return cleared;
},
[bumpRevision],
);
// Memoise so identity-based context propagation doesn't re-render every
// downstream consumer on each parent render.
const value = useMemo<IndexedDBContextValue>(
() => ({
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
loadLeafMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail,
markFileAsProcessed,
moveFilesToFolder,
clearFolderForFiles,
revision,
bumpRevision,
}),
[
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
loadLeafMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail,
markFileAsProcessed,
moveFilesToFolder,
clearFolderForFiles,
revision,
bumpRevision,
],
);
return (
<IndexedDBContext.Provider value={value}>
@@ -240,6 +240,9 @@ interface AddFileOptions {
// Auto-selection after adding
selectFiles?: boolean;
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
// Auto-unzip control
autoUnzip?: boolean;
autoUnzipFileLimit?: number;
@@ -498,8 +501,8 @@ export async function addFiles(
});
}
// Batch dispatch all files at once — one render instead of N sequential renders
if (stirlingFileStubs.length > 0) {
// Batch dispatch in one render. Suppressed by skipWorkspaceDispatch.
if (stirlingFileStubs.length > 0 && !options.skipWorkspaceDispatch) {
dispatch({ type: "ADD_FILES", payload: { stirlingFileStubs } });
}
@@ -8,7 +8,12 @@ export const useFileHandler = () => {
const addFiles = useCallback(
async (
files: File[],
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
options: {
insertAfterPageId?: string;
selectFiles?: boolean;
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
} = {},
): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
const mergedOptions = { selectFiles: true, ...options };
@@ -93,6 +93,16 @@
touch-action: pan-x pinch-zoom;
}
/* /files mobile: skip the slider entirely. The FileManagerView fills the
viewport between the (suppressed) top toggle and the bottom nav, getting
back ~120px of vertical room and avoiding the side-scroll surface. */
.mobile-files-full {
display: flex;
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.mobile-slider::-webkit-scrollbar {
display: none;
}
+321 -147
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
@@ -15,18 +15,27 @@ import {
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import AppsIcon from "@mui/icons-material/AppsRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import {
FilesPageProvider,
useFilesPage,
} from "@app/contexts/FilesPageContext";
import { useFolders } from "@app/contexts/FolderContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
import "@app/pages/HomePage.css";
@@ -49,15 +58,18 @@ export default function HomePage() {
customWorkbenchViews,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const navigate = useNavigate();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
const location = useLocation();
// Collapse the sidebar when mounting directly on /files.
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(() =>
location.pathname.startsWith("/files"),
);
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
// tour's openConfigModal action which navigates to /settings/overview).
@@ -69,6 +81,46 @@ export default function HomePage() {
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
const { actions } = useNavigationActions();
// Sync the /files* URL into the workbench state so the file manager view
// takes over the workbench area when the user lands on it. This is the
// only state-of-truth for the active workbench, so keep the URL pinned.
useEffect(() => {
if (location.pathname.startsWith("/files")) {
if (navigationState.workbench !== "myFiles") {
actions.setWorkbench("myFiles");
}
} else if (navigationState.workbench === "myFiles") {
// Leaving the file manager - drop back to a sensible default.
actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer");
}
}, [
location.pathname,
navigationState.workbench,
actions,
activeFiles.length,
]);
// Auto-collapse the FileSidebar on /files; snapshot prior state for restore.
const previousSidebarCollapsedRef = useRef<boolean | null>(null);
const prevWorkbenchRef = useRef(navigationState.workbench);
useEffect(() => {
const prev = prevWorkbenchRef.current;
const curr = navigationState.workbench;
if (curr === "myFiles" && prev !== "myFiles") {
previousSidebarCollapsedRef.current = fileSidebarCollapsed;
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
} else if (
curr !== "myFiles" &&
prev === "myFiles" &&
previousSidebarCollapsedRef.current !== null
) {
setFileSidebarCollapsed(previousSidebarCollapsedRef.current);
previousSidebarCollapsedRef.current = null;
}
prevWorkbenchRef.current = curr;
// fileSidebarCollapsed read as snapshot on transition only.
}, [navigationState.workbench]);
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
@@ -79,13 +131,6 @@ export default function HomePage() {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
console.log("[HomePage] Navigation effect triggered:", {
prevCount,
currentCount,
currentWorkbench: navigationState.workbench,
selectedToolKey,
});
const action = getStartupNavigationAction(
prevCount,
currentCount,
@@ -93,16 +138,11 @@ export default function HomePage() {
navigationState.workbench,
);
console.log("[HomePage] Navigation action returned:", action);
if (action) {
console.log("[HomePage] Applying navigation:", action);
actions.setWorkbench(action.workbench);
if (typeof action.activeFileIndex === "number") {
setActiveFileIndex(action.activeFileIndex);
}
} else {
console.log("[HomePage] No navigation - staying in current workbench");
}
prevFileCountRef.current = currentCount;
@@ -115,9 +155,11 @@ export default function HomePage() {
]);
const hideToolPanel =
customWorkbenchViews.find(
navigationState.workbench === "myFiles" ||
(customWorkbenchViews.find(
(v) => v.workbenchId === navigationState.workbench,
)?.hideToolPanel ?? false;
)?.hideToolPanel ??
false);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
@@ -241,154 +283,286 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<HomePageExtensions />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<LogoIcon className="mobile-brand-icon" />
<Wordmark alt={brandAltText} className="mobile-brand-text" />
<FilesPageProvider>
{isMobile ? (
<div
className="mobile-layout"
data-files-mode={navigationState.workbench === "myFiles"}
>
{/* On /files the FileManagerView already has its own Back +
breadcrumb + tabs chrome - the tools/workspace toggle would
just duplicate vertical space. Keep the toggle on every
other route. */}
{navigationState.workbench !== "myFiles" && (
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<LogoIcon className="mobile-brand-icon" />
<Wordmark
alt={brandAltText}
className="mobile-brand-text"
/>
</div>
</div>
<div
className="mobile-toggle-buttons"
role="tablist"
aria-label={t(
"home.mobile.viewSwitcher",
"Switch workspace view",
)}
>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "tools"}
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
onClick={() => handleSelectMobileView("tools")}
>
{t("home.mobile.tools", "Tools")}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "workbench"}
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
onClick={() => handleSelectMobileView("workbench")}
>
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
</div>
</div>
<div
className="mobile-toggle-buttons"
role="tablist"
aria-label={t(
"home.mobile.viewSwitcher",
"Switch workspace view",
)}
>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "tools"}
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
onClick={() => handleSelectMobileView("tools")}
>
{t("home.mobile.tools", "Tools")}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "workbench"}
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
onClick={() => handleSelectMobileView("workbench")}
>
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
</div>
<div ref={sliderRef} className="mobile-slider">
<div
className="mobile-slide"
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div
className="mobile-slide"
aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}
>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex">
)}
{navigationState.workbench === "myFiles" ? (
/* /files takes the whole viewport. Skipping the slider keeps
the FileManagerView from being trapped inside a 100vw
horizontal-scroll container (which truncated buttons and
created a stray side-scroll surface on touch). */
<div className="mobile-files-full">
<div className="flex-1 min-h-0 flex" style={{ minWidth: 0 }}>
<Workbench />
</div>
</div>
</div>
</div>
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.allTools", "Tools")}
onClick={() => {
handleBackToTools();
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<AppsIcon sx={{ fontSize: "1.5rem" }} />
<span className="mobile-bottom-button-label">
{t("quickAccess.allTools", "Tools")}
</span>
</button>
{toolAvailability["automate"]?.available !== false && (
) : (
<div ref={sliderRef} className="mobile-slider">
<div
className="mobile-slide"
aria-label={t(
"home.mobile.toolsSlide",
"Tool selection panel",
)}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div
className="mobile-slide"
aria-label={t(
"home.mobile.workbenchSlide",
"Workspace panel",
)}
>
<div className="mobile-slide-content">
<div
className="flex-1 min-h-0 flex"
style={{ minWidth: 0 }}
>
<Workbench />
</div>
</div>
</div>
</div>
)}
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.automate", "Automate")}
aria-label={t("quickAccess.allTools", "Tools")}
onClick={() => {
handleToolSelect("automate");
handleBackToTools();
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<AppsIcon sx={{ fontSize: "1.5rem" }} />
<span className="mobile-bottom-button-label">
{t("quickAccess.allTools", "Tools")}
</span>
</button>
{toolAvailability["automate"]?.available !== false && (
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.automate", "Automate")}
onClick={() => {
handleToolSelect("automate");
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<LocalIcon
icon="automation-outline"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.automate", "Automate")}
</span>
</button>
)}
<button
className="mobile-bottom-button"
aria-label={t("home.mobile.openFiles", "Open files")}
onClick={() => navigate("/files")}
>
<LocalIcon
icon="automation-outline"
icon="folder-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.automate", "Automate")}
{t("quickAccess.files", "Files")}
</span>
</button>
)}
<button
className="mobile-bottom-button"
aria-label={t("home.mobile.openFiles", "Open files")}
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">
{t("quickAccess.files", "Files")}
</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.config", "Config")}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon
icon="settings-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.config", "Config")}
</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.config", "Config")}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon
icon="settings-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.config", "Config")}
</span>
</button>
</div>
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
<FileSidebar
ref={quickAccessRef}
collapsed={fileSidebarCollapsed}
onToggleCollapse={() => setFileSidebarCollapsed((c) => !c)}
onOpenSettings={() => setConfigModalOpen(true)}
/>
<Workbench />
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</Group>
)}
) : (
<Group
align="flex-start"
gap={0}
h="100%"
className="flex-nowrap flex"
>
<MyFilesAwareFileSidebar
ref={quickAccessRef}
active={navigationState.workbench === "myFiles"}
collapsed={fileSidebarCollapsed}
toggleAriaLabel={
navigationState.workbench === "myFiles"
? t("fileSidebar.leaveMyFiles", "Leave My Files")
: undefined
}
// Back-arrow on /files; burger elsewhere.
toggleIcon={
navigationState.workbench === "myFiles" ? (
<ArrowBackIcon />
) : undefined
}
onToggleCollapse={() => {
if (navigationState.workbench === "myFiles") {
navigate("/");
return;
}
setFileSidebarCollapsed((c) => !c);
}}
onOpenSettings={() => setConfigModalOpen(true)}
/>
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
<Workbench />
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</Group>
)}
</FilesPageProvider>
</div>
);
}
interface MyFilesAwareFileSidebarProps extends FileSidebarProps {
active: boolean;
}
/** Wraps FileSidebar with /files-aware overrides when `active`. */
const MyFilesAwareFileSidebar = forwardRef<
HTMLDivElement,
MyFilesAwareFileSidebarProps
>(function MyFilesAwareFileSidebar(props, ref) {
const { active, ...rest } = props;
if (!active) {
return <FileSidebar ref={ref} {...rest} />;
}
return <MyFilesSidebarOverrides ref={ref} {...rest} />;
});
const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
function MyFilesSidebarOverrides(props, ref) {
const { t } = useTranslation();
const filesPage = useFilesPage();
const folders = useFolders();
const { addFiles } = useFileHandler();
const handleUpload = useCallback(
async (files: File[]) => {
const added = await addFiles(files, { skipWorkspaceDispatch: true });
await filesPage.refresh();
// If the user is inside a cloud folder, place uploads there.
if (folders.currentFolderId !== null && added.length > 0) {
await filesPage.moveFilesTo(
added.map((f) => f.fileId),
folders.currentFolderId,
);
}
},
[addFiles, filesPage, folders.currentFolderId],
);
const newFolderDisabledReason = !folders.serverReachable
? t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled. Ask your admin to turn it on.",
)
: null;
return (
<FileSidebar
ref={ref}
{...props}
onSearchClick={() => {
// Just focus the central search field; don't toggle collapse
// (which on /files navigates back home).
window.dispatchEvent(new Event("files-page:focus-search"));
}}
onUploadFiles={handleUpload}
onPickGoogleDriveFiles={handleUpload}
extraAction={{
icon: <CreateNewFolderIcon />,
label: t("filesPage.newFolder", "New folder"),
onClick: () => filesPage.openNewFolderDialog(),
disabled: newFolderDisabledReason !== null,
disabledTooltip: newFolderDisabledReason ?? undefined,
testId: "files-rail-new-folder",
}}
/>
);
},
);
+147 -28
View File
@@ -5,6 +5,7 @@
*/
import { FileId, BaseFileMetadata } from "@app/types/file";
import { FolderId } from "@app/types/folder";
import {
StirlingFile,
StirlingFileStub,
@@ -26,7 +27,7 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
thumbnail?: string;
thumbnailStoredAt?: number; // Epoch ms sliding 30-day TTL
thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL
url?: string; // For compatibility with existing components
}
@@ -66,7 +67,7 @@ class FileStorageService {
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
// Issue all gets up front each onsuccess creates a put before the
// Issue all gets up front - each onsuccess creates a put before the
// transaction can auto-commit, keeping it alive until all puts settle.
ids.forEach((id) => {
const req = store.get(id);
@@ -123,6 +124,9 @@ class FileStorageService {
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
parentFileId: stub.parentFileId ?? undefined,
toolHistory: stub.toolHistory ?? [],
// Folder organisation (root when null)
folderId: stub.folderId ?? null,
};
return new Promise((resolve, reject) => {
@@ -215,9 +219,12 @@ class FileStorageService {
return;
}
// Create StirlingFileStub from metadata (no file data)
// No per-id thumbnail TTL bump here - the bulk getAll/leaf paths
// already keep TTL fresh, and bumping on every single-id read
// generated a writable transaction per call (write amplification).
// We still gate thumbnailUrl on freshness so stale thumbnails
// don't leak through this read path.
const fresh = this.isThumbnailFresh(record);
void this.bumpThumbnailTTL([record.id], !fresh);
const stub: StirlingFileStub = {
id: record.id,
@@ -240,6 +247,7 @@ class FileStorageService {
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
};
@@ -295,13 +303,24 @@ class FileStorageService {
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
});
}
cursor.continue();
} else {
void this.bumpThumbnailTTL(tobump);
void this.bumpThumbnailTTL(toexpire, true);
// Only open the writeback transaction when there's something to do -
// previously fired two empty transactions per refresh.
if (tobump.length > 0) {
void this.bumpThumbnailTTL(tobump).catch((e) =>
console.warn("[fileStorage] thumbnail TTL bump failed", e),
);
}
if (toexpire.length > 0) {
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
console.warn("[fileStorage] thumbnail expire failed", e),
);
}
resolve(stubs);
}
};
@@ -372,19 +391,112 @@ class FileStorageService {
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
});
}
cursor.continue();
} else {
void this.bumpThumbnailTTL(tobump);
void this.bumpThumbnailTTL(toexpire, true);
if (tobump.length > 0) {
void this.bumpThumbnailTTL(tobump).catch((e) =>
console.warn("[fileStorage] thumbnail TTL bump failed", e),
);
}
if (toexpire.length > 0) {
void this.bumpThumbnailTTL(toexpire, true).catch((e) =>
console.warn("[fileStorage] thumbnail expire failed", e),
);
}
resolve(leafStubs);
}
};
});
}
/**
* Move one or more files into a folder (or to the root when folderId is null).
* Returns the ids of records that were actually updated.
*/
async moveFilesToFolder(
fileIds: FileId[],
folderId: FolderId | null,
): Promise<FileId[]> {
if (fileIds.length === 0) return [];
const db = await this.getDatabase();
const updated: FileId[] = [];
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("Move transaction aborted"));
fileIds.forEach((id) => {
const request = store.get(id);
request.onsuccess = () => {
const record = request.result as StoredStirlingFileRecord | undefined;
if (!record) return;
record.folderId = folderId;
store.put(record);
updated.push(id);
};
request.onerror = () => reject(request.error);
});
});
return updated;
}
/**
* Clear the folderId for every file currently inside any of the given
* folders. Used when a folder (or subtree) is deleted so the contents
* fall back to the root rather than dangling against a missing folder.
*/
async clearFolderForFiles(folderIds: FolderId[]): Promise<number> {
if (folderIds.length === 0) return 0;
const db = await this.getDatabase();
let cleared = 0;
// Use the `folderId` index (declared on the files store in
// indexedDBManager DATABASE_CONFIGS) with one keyRange-bounded cursor
// per folderId. The previous full-store openCursor() was O(total files);
// this is O(files-in-affected-folders + folderIds.length). On users
// with thousands of files and a single deleted folder this is a 100x+
// win and keeps the UI responsive while the transaction runs.
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const index = store.index("folderId");
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(
transaction.error ?? new Error("Clear folder transaction aborted"),
);
for (const folderId of folderIds) {
const cursorRequest = index.openCursor(
IDBKeyRange.only(folderId as string),
);
cursorRequest.onerror = () => reject(cursorRequest.error);
cursorRequest.onsuccess = (event) => {
const cursor = (event.target as IDBRequest)
.result as IDBCursorWithValue | null;
if (!cursor) return;
const record = cursor.value as StoredStirlingFileRecord;
record.folderId = null;
cursor.update(record);
cleared += 1;
cursor.continue();
};
}
});
return cleared;
}
/**
* Delete StirlingFile - single operation, no sync issues
*/
@@ -628,6 +740,11 @@ class FileStorageService {
/**
* Update metadata fields for a stored file record.
*
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
* so callers only receive `true` once the write actually commits. If the
* transaction aborts after put() succeeded but before commit, we return false
* - the previous behavior incorrectly claimed success in that window.
*/
async updateFileMetadata(
fileId: FileId,
@@ -635,29 +752,31 @@ class FileStorageService {
): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>(
(resolve, reject) => {
const request = store.get(fileId);
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve(request.result as StoredStirlingFileRecord | undefined);
},
);
return await new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
let recordFound = false;
if (!record) {
return false;
}
const getRequest = store.get(fileId);
getRequest.onsuccess = () => {
const record = getRequest.result as
| StoredStirlingFileRecord
| undefined;
if (!record) {
// Don't commit anything; caller wants false.
return;
}
recordFound = true;
const updatedRecord = { ...record, ...updates };
store.put(updatedRecord);
};
getRequest.onerror = () => reject(getRequest.error);
const updatedRecord = { ...record, ...updates };
await new Promise<void>((resolve, reject) => {
const request = store.put(updatedRecord);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
transaction.oncomplete = () => resolve(recordFound);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
});
return true;
} catch (error) {
console.error("Failed to update file metadata:", error);
return false;
@@ -0,0 +1,498 @@
/**
* Reconciles the local IDB file stub list with the server's view of which
* files the user can see. Server is source of truth for cloud files; local
* IDB caches them so the grid renders instantly while the server call is in
* flight and so local-only files survive offline.
*/
import apiClient from "@app/services/apiClient";
import { fileStorage } from "@app/services/fileStorage";
import { alert } from "@app/components/toast";
import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
import { FileId } from "@app/types/fileContext";
import { FolderId, parseFolderId } from "@app/types/folder";
import {
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from "@app/services/shareBundleUtils";
/**
* Trust-boundary parser for folderId values coming back from the server.
* Matches folderSyncService's discipline - we never want a garbage server
* payload to corrupt the local IDB `folderId` index. Returns null on any
* invalid input so the file falls back to the root folder.
*/
function safeParseFolderId(value: unknown): FolderId | null {
if (value == null || value === "") return null;
try {
return parseFolderId(value);
} catch {
console.warn("[fileSyncService] dropping invalid server folderId", value);
return null;
}
}
interface StoredFileResponse {
id: number;
fileName: string;
contentType?: string | null;
sizeBytes: number;
createdAt?: string | null;
updatedAt?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
shareLinks?: Array<{ token?: string | null }>;
sharedUsers?: Array<{ username?: string | null }>;
sharedWithUsers?: string[];
filePurpose?: string | null;
folderId?: string | null;
}
interface AccessedShareLinkResponse {
shareToken?: string | null;
fileId?: number | null;
fileName?: string | null;
owner?: string | null;
ownedByCurrentUser?: boolean;
createdAt?: string | null;
lastAccessedAt?: string | null;
}
export interface ReconcileOptions {
storageEnabled: boolean;
shareLinksEnabled: boolean;
}
function normalizeServerFileName(fileName: string | undefined | null): string {
const fallback = fileName?.trim() || "server-file";
const lower = fallback.toLowerCase();
const historySuffix = "-history.zip";
if (lower.endsWith(historySuffix)) {
return (
fallback.slice(0, fallback.length - historySuffix.length) || fallback
);
}
if (lower.endsWith(".zip")) {
const innerExts = [
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"png",
"jpg",
"jpeg",
"tif",
"tiff",
"txt",
"csv",
"rtf",
"html",
"epub",
];
for (const ext of innerExts) {
if (lower.endsWith(`.${ext}.zip`)) {
return fallback.slice(0, fallback.length - 4) || fallback;
}
}
}
return fallback;
}
/** Pull the server file list (and share-links) and reconcile with local stubs. */
export async function reconcileServerFiles(
localStubs: StirlingFileStub[],
opts: ReconcileOptions,
): Promise<StirlingFileStub[]> {
if (!opts.storageEnabled) {
return localStubs;
}
let combinedStubs: StirlingFileStub[];
const localRemoteIds = new Set(
localStubs
.map((s) => s.remoteStorageId)
.filter((id): id is number => typeof id === "number"),
);
try {
const response = await apiClient.get<StoredFileResponse[]>(
"/api/v1/storage/files",
{
suppressErrorToast: true,
skipAuthRedirect: true,
} as any,
);
const serverFiles = Array.isArray(response.data) ? response.data : [];
const serverMap = new Map<number, StoredFileResponse>();
for (const file of serverFiles) {
if (file && typeof file.id === "number") {
serverMap.set(file.id, file);
}
}
const updatedLocalStubs = localStubs.map((stub) => {
if (!stub.remoteStorageId) {
return stub;
}
const serverFile = serverMap.get(stub.remoteStorageId);
if (!serverFile) {
// Server no longer knows this file; if it was a shared-link link,
// demote rather than detach. Otherwise drop remote metadata so the
// file becomes local-only.
if (stub.remoteSharedViaLink) {
return { ...stub, remoteOwnedByCurrentUser: false };
}
return {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteAccessRole: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
};
}
if (serverFile.filePurpose && serverFile.filePurpose !== "generic") {
// Signing-workflow files aren't user-facing in the file manager.
return {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteAccessRole: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
};
}
const updatedAtMs = serverFile.updatedAt
? new Date(serverFile.updatedAt).getTime()
: serverFile.createdAt
? new Date(serverFile.createdAt).getTime()
: undefined;
return {
...stub,
remoteOwnerUsername: serverFile.owner ?? stub.remoteOwnerUsername,
remoteOwnedByCurrentUser:
typeof serverFile.ownedByCurrentUser === "boolean"
? serverFile.ownedByCurrentUser
: stub.remoteOwnedByCurrentUser,
remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: Boolean(serverFile.shareLinks?.length),
remoteHasUserShares: Boolean(
serverFile.sharedUsers?.length || serverFile.sharedWithUsers?.length,
),
remoteStorageUpdatedAt:
typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)
? updatedAtMs
: stub.remoteStorageUpdatedAt,
folderId: safeParseFolderId(serverFile.folderId) ?? stub.folderId,
};
});
// Server files that this browser hasn't cached yet become ephemeral
// stubs (id="server-{N}", no IDB row). Bytes get fetched on demand.
const serverStubs: StirlingFileStub[] = [];
for (const file of serverFiles) {
if (!file || typeof file.id !== "number") continue;
if (localRemoteIds.has(file.id)) continue;
if (file.filePurpose && file.filePurpose !== "generic") continue;
const updatedAtMs = file.updatedAt
? new Date(file.updatedAt).getTime()
: file.createdAt
? new Date(file.createdAt).getTime()
: Date.now();
const name = normalizeServerFileName(file.fileName);
const lastModified = Number.isFinite(updatedAtMs)
? updatedAtMs
: Date.now();
const id = `server-${file.id}` as FileId;
serverStubs.push({
id,
name,
type: file.contentType || "application/octet-stream",
size: file.sizeBytes ?? 0,
lastModified,
createdAt: lastModified,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
toolHistory: [],
quickKey: `${name}|${file.sizeBytes ?? 0}|${lastModified}`,
remoteStorageId: file.id,
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: file.owner ?? undefined,
remoteOwnedByCurrentUser:
typeof file.ownedByCurrentUser === "boolean"
? file.ownedByCurrentUser
: undefined,
remoteAccessRole: file.accessRole ?? undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: Boolean(file.shareLinks?.length),
remoteHasUserShares: Boolean(
file.sharedUsers?.length || file.sharedWithUsers?.length,
),
folderId: safeParseFolderId(file.folderId),
});
}
combinedStubs = [...updatedLocalStubs, ...serverStubs];
} catch (err) {
// Surface to the user so they don't silently see only locally-cached
// files and assume their cloud data is lost. Toast deduplicates by title
// so a repeated failure isn't an avalanche of identical popups.
const status = (err as { response?: { status?: number } })?.response
?.status;
console.warn("[fileSyncService] failed to pull server files", err);
alert({
alertType: "warning",
title:
status === 401
? "Sign-in required to load cloud files"
: "Could not reach the cloud library",
body: "Showing only files cached in this browser. Refresh to retry once the connection is back.",
expandable: false,
durationMs: 5000,
});
return localStubs;
}
if (!opts.shareLinksEnabled) {
return combinedStubs;
}
try {
const response = await apiClient.get<AccessedShareLinkResponse[]>(
"/api/v1/storage/share-links/accessed",
{ suppressErrorToast: true, skipAuthRedirect: true } as any,
);
const sharedLinks = Array.isArray(response.data) ? response.data : [];
const allowed = new Set(
sharedLinks
.map((l) => l.shareToken)
.filter((t): t is string => Boolean(t)),
);
const writes: Array<Promise<boolean>> = [];
combinedStubs = combinedStubs.map((stub) => {
if (
stub.remoteSharedViaLink &&
stub.remoteShareToken &&
!allowed.has(stub.remoteShareToken)
) {
writes.push(
fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
remoteShareToken: undefined,
}),
);
return {
...stub,
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
remoteOwnerUsername: undefined,
remoteOwnedByCurrentUser: undefined,
remoteSharedViaLink: false,
remoteHasShareLinks: undefined,
remoteShareToken: undefined,
};
}
return stub;
});
if (writes.length > 0) {
// Fire-and-forget; the in-memory list is the user-visible source.
void Promise.all(writes).catch(() => {});
}
// Synthesize ephemeral shared-{token} stubs for share-links the user has
// accessed but doesn't have cached locally yet. Materialize on demand.
const existingShareTokens = new Set(
combinedStubs
.map((stub) => stub.remoteShareToken)
.filter((token): token is string => Boolean(token)),
);
const sharedStubs: StirlingFileStub[] = [];
for (const link of sharedLinks) {
if (!link || !link.shareToken) continue;
if (existingShareTokens.has(link.shareToken)) continue;
const accessedMs = link.lastAccessedAt
? new Date(link.lastAccessedAt).getTime()
: link.createdAt
? new Date(link.createdAt).getTime()
: Date.now();
const lastModified = Number.isFinite(accessedMs)
? accessedMs
: Date.now();
const name = normalizeServerFileName(link.fileName || "shared-file");
const id = `shared-${link.shareToken}` as FileId;
sharedStubs.push({
id,
name,
type: "application/octet-stream",
size: 0,
lastModified,
createdAt: lastModified,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
toolHistory: [],
quickKey: `${name}|0|${lastModified}`,
remoteStorageId: link.fileId ?? undefined,
remoteStorageUpdatedAt: lastModified,
remoteOwnerUsername: link.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: link.shareToken,
});
}
combinedStubs = [...combinedStubs, ...sharedStubs];
} catch (err) {
console.warn("[fileSyncService] failed to pull share-links", err);
}
return combinedStubs;
}
/**
* Download bytes for any server-only stubs (id starts with "server-") and
* ingest them into IDB. Returns a stub list where the server-only entries
* are replaced with proper local stubs that point to the freshly-cached
* IDB rows. Local stubs are passed through untouched.
*
* Pass `addFiles` (from FileContext.addFilesWithOptions) and
* `updateStub` (from FileContext.updateStirlingFileStub) so this util
* stays React-free; the caller provides the wiring.
*/
export async function materializeServerStubs(
stubs: StirlingFileStub[],
helpers: {
addFiles: (
files: File[],
options: {
selectFiles: boolean;
autoUnzip: boolean;
skipAutoUnzip: boolean;
allowDuplicates: boolean;
},
) => Promise<StirlingFile[]>;
updateStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
},
): Promise<StirlingFileStub[]> {
const out: StirlingFileStub[] = [];
// Collect per-stub failures so we can surface ONE summarized toast at the
// end instead of N popups (or worse, silently dropping files from the grid
// with zero user signal as the previous code did).
const failed: { name: string; status?: number }[] = [];
for (const stub of stubs) {
const isServerStub =
typeof stub.id === "string" &&
stub.id.startsWith("server-") &&
typeof stub.remoteStorageId === "number";
const isSharedStub =
typeof stub.id === "string" &&
stub.id.startsWith("shared-") &&
typeof stub.remoteShareToken === "string";
if (!isServerStub && !isSharedStub) {
out.push(stub);
continue;
}
try {
const downloadUrl = isSharedStub
? `/api/v1/storage/share-links/${stub.remoteShareToken}`
: `/api/v1/storage/files/${stub.remoteStorageId}/download`;
const response = await apiClient.get(downloadUrl, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const rawHeaders = (response.headers ?? {}) as Record<string, unknown> & {
get?: (name: string) => string | null;
};
const readHeader = (name: string): string => {
if (typeof rawHeaders.get === "function") {
return rawHeaders.get(name) ?? "";
}
const lower = rawHeaders[name];
const upper =
rawHeaders[
name.replace(/(^|-)([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase())
];
return (
(typeof lower === "string" && lower) ||
(typeof upper === "string" && upper) ||
""
);
};
const contentType = readHeader("content-type");
const disposition = readHeader("content-disposition");
const filename =
parseContentDispositionFilename(disposition) || stub.name;
const blob = response.data as Blob;
// Server bundle: extract latest file(s) inside.
const bundle = isZipBundle(contentType, filename)
? await loadShareBundleEntries(blob).catch(() => null)
: null;
const files: File[] = bundle
? bundle.files
: [new File([blob], filename, { type: contentType || blob.type })];
const ingested = await helpers.addFiles(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: true,
allowDuplicates: true,
});
if (ingested.length === 0) continue;
const primary = ingested[ingested.length - 1]!;
const newId = primary.fileId as FileId;
const remoteUpdates = {
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
remoteSharedViaLink: isSharedStub ? true : false,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: isSharedStub ? stub.remoteShareToken : undefined,
};
helpers.updateStub(newId, remoteUpdates);
await fileStorage.updateFileMetadata(newId, remoteUpdates);
out.push({ ...stub, ...remoteUpdates, id: newId, originalFileId: newId });
} catch (err) {
console.warn("[fileSyncService] failed to materialize server stub", err);
const status = (err as { response?: { status?: number } })?.response
?.status;
failed.push({ name: stub.name, status });
}
}
if (failed.length > 0) {
// Single summarized toast - far less noisy than per-stub alerts but
// still surfaces what would otherwise be a silent drop from the grid.
const first = failed[0]!;
const bodyText =
failed.length === 1
? `Couldn't open "${first.name}"${first.status ? ` (HTTP ${first.status})` : ""}.`
: `Couldn't open ${failed.length} files including "${first.name}".`;
alert({
alertType: "warning",
title: "Some files couldn't be opened",
body: bodyText,
expandable: false,
durationMs: 5000,
});
}
return out;
}
@@ -0,0 +1,119 @@
/**
* Folder Storage Service - passive read-cache of the server's folder hierarchy.
*
* Folders are server-owned. This module just persists the most recent server
* response so the UI can paint instantly on next mount (and remain readable
* offline). Every mutation must go through {@code folderSyncService} first;
* on success the caller invokes {@link FolderStorageService.replaceAll} or
* one of the targeted updaters to keep the cache in step.
*
* No id generation here, no cycle detection, no idempotency tricks - those
* are all the server's job now.
*/
import { FolderId, FolderRecord } from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
class FolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "folders";
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.dbConfig);
}
/**
* Atomically replace the entire cached folder set with the supplied list.
* Used after a successful {@code pullFromServer} - the server is the
* source of truth, so any folder absent from the response is dropped
* locally too (no orphan rows surviving a server-side delete).
*/
async replaceAll(folders: FolderRecord[]): Promise<void> {
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("folder cache replace failed"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("folder cache replace aborted"));
store.clear();
for (const folder of folders) {
store.put(folder);
}
});
}
/** Insert or overwrite a single folder in the cache. */
async upsertFolder(folder: FolderRecord): Promise<void> {
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.put(folder);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
/** Remove a set of folders from the cache (after a successful server delete). */
async removeFolders(ids: FolderId[]): Promise<void> {
if (ids.length === 0) return;
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("folder cache delete failed"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("folder cache delete aborted"));
for (const id of ids) store.delete(id);
});
}
async getAllFolders(): Promise<FolderRecord[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const records = (request.result as FolderRecord[]) ?? [];
resolve(records);
};
});
}
async getFolder(id: FolderId): Promise<FolderRecord | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const record = request.result as FolderRecord | undefined;
resolve(record ?? null);
};
});
}
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
export const folderStorage = new FolderStorageService();
@@ -0,0 +1,146 @@
/**
* Thin HTTP client for the Phase A folder endpoints.
*
* Returns DTOs that look like the local {@link FolderRecord}; the
* caller is responsible for merging them into IndexedDB via
* {@link folderStorage}.
*/
import apiClient from "@app/services/apiClient";
import { FolderId, FolderRecord, parseFolderId } from "@app/types/folder";
interface ServerFolder {
id: string;
name: string;
parentFolderId: string | null;
color: string | null;
icon: string | null;
version: number | null;
// ISO timestamps - older server builds occasionally send `null` when
// the entity hasn't been flushed; we defend against that in parseTimestamp.
createdAt: string | null;
updatedAt: string | null;
}
function parseTimestamp(
value: string | null | undefined,
field: string,
): number {
// Tolerate null/missing - older server builds may serialise pre-flush
// timestamps as null. Log a warning so a real schema drift still gets
// attention, but fall back to "now" rather than failing the whole pull.
if (value == null || value === "") {
console.warn(
`[folderSyncService] missing ${field} from server response; defaulting to now`,
);
return Date.now();
}
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
throw new Error(`Invalid ${field} timestamp from server: ${value}`);
}
return ms;
}
function toFolderRecord(dto: ServerFolder): FolderRecord {
// Validate at the trust boundary - server may have a bug or contract drift.
// parseFolderId throws if `dto.id` isn't a UUID; better a loud failure than
// a corrupt local cache.
const id = parseFolderId(dto.id);
const parentFolderId =
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
return {
id,
name: dto.name,
parentFolderId,
color: dto.color ?? undefined,
icon: dto.icon ?? undefined,
createdAt: parseTimestamp(dto.createdAt, "createdAt"),
updatedAt: parseTimestamp(dto.updatedAt, "updatedAt"),
};
}
export const folderSyncService = {
async list(): Promise<FolderRecord[]> {
const response = await apiClient.get<ServerFolder[]>(
"/api/v1/storage/folders",
);
return (response.data ?? []).map(toFolderRecord);
},
async create(input: {
id?: FolderId;
name: string;
parentFolderId: FolderId | null;
color?: string;
icon?: string;
}): Promise<FolderRecord> {
const response = await apiClient.post<ServerFolder>(
"/api/v1/storage/folders",
{
id: input.id ?? undefined,
name: input.name,
parentFolderId: input.parentFolderId,
color: input.color,
icon: input.icon,
},
);
return toFolderRecord(response.data);
},
async update(
id: FolderId,
patch: {
name?: string;
reparent?: boolean;
parentFolderId?: FolderId | null;
color?: string | null;
icon?: string | null;
},
): Promise<FolderRecord> {
const body: Record<string, unknown> = {};
if (patch.name !== undefined) body.name = patch.name;
if (patch.reparent) {
body.reparent = true;
body.parentFolderId = patch.parentFolderId ?? null;
}
if (patch.color !== undefined) body.color = patch.color ?? "";
if (patch.icon !== undefined) body.icon = patch.icon ?? "";
const response = await apiClient.patch<ServerFolder>(
`/api/v1/storage/folders/${id}`,
body,
);
return toFolderRecord(response.data);
},
async delete(id: FolderId): Promise<FolderId[]> {
const response = await apiClient.delete<{ removedFolderIds: string[] }>(
`/api/v1/storage/folders/${id}`,
);
// Validate at the trust boundary - same posture as toFolderRecord.
return (response.data?.removedFolderIds ?? []).map(parseFolderId);
},
async moveFileToFolder(
fileRemoteId: number,
folderId: FolderId | null,
): Promise<void> {
await apiClient.patch(`/api/v1/storage/files/${fileRemoteId}/folder`, {
folderId,
});
},
async bulkMoveFiles(
fileRemoteIds: number[],
folderId: FolderId | null,
): Promise<{ movedFileIds: number[]; skippedFileIds: number[] }> {
const response = await apiClient.patch<{
movedFileIds: number[];
skippedFileIds: number[];
}>("/api/v1/storage/files/folder", {
folderId,
fileIds: fileRemoteIds,
});
return response.data;
},
};
@@ -0,0 +1,196 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import {
DATABASE_CONFIGS,
indexedDBManager,
} from "@app/services/indexedDBManager";
/**
* Regression test for the IDB v2->v4 migration silent-clobber bug fixed in
* commit 57e056026 "Merge IDB migrations". Pre-fix: two separate cursor
* walks (v2->v3 and v3->v4) ran in the same versionchange transaction;
* the second cursor's `value` was a structured-clone snapshot taken
* BEFORE the first cursor's update() committed, so the v4 walk wrote
* back its pre-v3 snapshot and silently erased every v3 field on every
* row (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory).
*
* A future v5 migration written as a third separate cursor walk would
* re-introduce the exact same failure mode for anyone jumping multiple
* versions. This test pins the behaviour by seeding a v2 DB and asserting
* every v3+v4 field is present and correctly set after the upgrade.
*/
const DB_NAME = DATABASE_CONFIGS.FILES.name;
const TARGET_VERSION = DATABASE_CONFIGS.FILES.version;
/**
* Open a v2-shaped FILES DB and seed it with two v2-shape records. v2
* had only the `files` store with `id` as keyPath; none of the v3 fields
* (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory) or
* the v4 field (folderId) yet existed on records.
*/
function seedV2Database(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 2);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id" });
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("files", "readwrite");
const store = tx.objectStore("files");
store.add({
id: "file-a",
name: "alpha.pdf",
type: "application/pdf",
size: 1024,
lastModified: 1000,
data: new Blob(["hello"], { type: "application/pdf" }),
});
store.add({
id: "file-b",
name: "beta.pdf",
type: "application/pdf",
size: 2048,
lastModified: 2000,
data: new Blob(["world"], { type: "application/pdf" }),
});
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error ?? new Error("seed tx failed"));
};
req.onerror = () => reject(req.error ?? new Error("seed open failed"));
});
}
/**
* Seed a v3-shape DB (so we can also verify that a v3->v4 jump still
* lands correctly under the merged migration).
*/
function seedV3Database(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 3);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id" });
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("files", "readwrite");
const store = tx.objectStore("files");
store.add({
id: "file-c",
name: "gamma.pdf",
type: "application/pdf",
size: 4096,
lastModified: 3000,
data: new Blob(["v3"], { type: "application/pdf" }),
// v3 fields, set explicitly so this is genuinely a v3 record
isLeaf: true,
versionNumber: 1,
originalFileId: "file-c",
parentFileId: undefined,
toolHistory: [],
});
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error ?? new Error("v3 seed tx failed"));
};
req.onerror = () => reject(req.error ?? new Error("v3 seed open failed"));
});
}
function readAllFiles(): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("files", "readonly");
const store = tx.objectStore("files");
const all = store.getAll();
all.onsuccess = () => {
db.close();
resolve(all.result);
};
all.onerror = () => reject(all.error);
};
req.onerror = () => reject(req.error);
});
}
async function cleanup(): Promise<void> {
indexedDBManager.closeAllDatabases();
await new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve(); // best-effort in jsdom
});
}
describe("IndexedDB migration (FILES store)", () => {
beforeEach(async () => {
await cleanup();
});
test("v2 -> latest applies v3 AND v4 fields without clobbering", async () => {
await seedV2Database();
// Trigger the migration via the production code path.
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows).toHaveLength(2);
for (const row of rows) {
// v3 fields - this is the critical assertion. Pre-fix, the v4
// cursor walk's structured-clone snapshot would write these back
// as undefined, silently erasing them.
expect(row.isLeaf).toBe(true);
expect(row.versionNumber).toBe(1);
expect(row.originalFileId).toBe(row.id);
expect(row.toolHistory).toEqual([]);
// parentFileId is intentionally set to undefined for legacy records
// (they're version roots, not children of anything).
expect(row.parentFileId).toBeUndefined();
// v4 field
expect(row.folderId).toBeNull();
}
});
test("v3 -> latest applies v4 folderId without erasing existing v3 fields", async () => {
await seedV3Database();
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
const row = rows[0]!;
// Existing v3 fields should be unchanged
expect(row.isLeaf).toBe(true);
expect(row.versionNumber).toBe(1);
expect(row.originalFileId).toBe("file-c");
expect(row.toolHistory).toEqual([]);
// v4 field added
expect(row.folderId).toBeNull();
});
test("fresh install at latest version requires no migration", async () => {
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
indexedDBManager.closeDatabase(DB_NAME);
});
});
@@ -147,7 +147,7 @@ class IndexedDBManager {
storeConfig.name === "files" &&
store
) {
this.migrateFileHistoryFields(store, oldVersion);
this.migrateFilesStore(store, oldVersion);
}
});
};
@@ -155,78 +155,97 @@ class IndexedDBManager {
}
/**
* Migrate existing file records to include new file history fields
* Single-pass migration for the `files` store on stirling-pdf-files.
*
* Runs ONE openCursor() walk and applies every applicable per-version
* delta to each record before `cursor.update()` writes it back. The
* previous design called migrateFileHistoryFields and migrateFolderField
* as two separate cursor walks inside the same onupgradeneeded
* transaction; their requests interleaved in the IDB request queue so
* the second walk's `cursor.value` was a stale snapshot taken before
* the first walk's `update()` had been processed - the second
* `update()` then wrote that stale object back, silently erasing
* isLeaf / versionNumber / originalFileId / parentFileId / toolHistory
* on every row both cursors touched. Folding into one cursor + one
* write per record removes the race entirely.
*
* New per-version blocks should be added as additional
* `if (oldVersion < N) { ... }` sections below.
*/
private migrateFileHistoryFields(
store: IDBObjectStore,
oldVersion: number,
): void {
// Only migrate if upgrading from a version before file history was added (version < 3)
if (oldVersion >= 3) {
return;
}
console.log("Starting file history migration for existing records...");
private migrateFilesStore(store: IDBObjectStore, oldVersion: number): void {
if (oldVersion >= 4) return; // nothing to migrate at the current schema
const cursor = store.openCursor();
let migratedCount = 0;
let migrated = 0;
cursor.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
const record = cursor.value;
let needsUpdate = false;
// Add missing file history fields with sensible defaults
if (record.isLeaf === undefined) {
record.isLeaf = true; // Existing files are unprocessed, should appear in recent files
needsUpdate = true;
}
if (record.versionNumber === undefined) {
record.versionNumber = 1; // Existing files are first version
needsUpdate = true;
}
if (record.originalFileId === undefined) {
record.originalFileId = record.id; // Existing files are their own root
needsUpdate = true;
}
if (record.parentFileId === undefined) {
record.parentFileId = undefined; // No parent for existing files
needsUpdate = true;
}
if (record.toolHistory === undefined) {
record.toolHistory = []; // No history for existing files
needsUpdate = true;
}
// Update the record if any fields were missing
if (needsUpdate) {
try {
cursor.update(record);
migratedCount++;
} catch (error) {
console.error("Failed to migrate record:", record.id, error);
}
}
cursor.continue();
} else {
// Migration complete
console.log(
`File history migration completed. Migrated ${migratedCount} records.`,
);
const result = (event.target as IDBRequest)
.result as IDBCursorWithValue | null;
if (!result) {
console.log(`Files-store migration complete (${migrated} records).`);
return;
}
const record = result.value;
let needsUpdate = false;
// v3: file history fields. Sensible defaults so existing files keep
// showing up in the recent view and act as their own version root.
if (oldVersion < 3) {
if (record.isLeaf === undefined) {
record.isLeaf = true;
needsUpdate = true;
}
if (record.versionNumber === undefined) {
record.versionNumber = 1;
needsUpdate = true;
}
if (record.originalFileId === undefined) {
record.originalFileId = record.id;
needsUpdate = true;
}
if (record.parentFileId === undefined) {
record.parentFileId = undefined;
needsUpdate = true;
}
if (record.toolHistory === undefined) {
record.toolHistory = [];
needsUpdate = true;
}
}
// v4: folderId. Required to exist on every row so the folderId
// index doesn't drop the record out of bounded-key cursor scans.
if (oldVersion < 4 && record.folderId === undefined) {
record.folderId = null;
needsUpdate = true;
}
if (needsUpdate) {
try {
result.update(record);
migrated += 1;
} catch (error) {
// Aborting the upgrade transaction here forces IndexedDB to roll back
// the schema version bump too - the user retries on next page load
// instead of silently losing folderId / isLeaf / etc on partial rows.
console.error("Failed to migrate record:", record.id, error);
store.transaction.abort();
return;
}
}
result.continue();
};
cursor.onerror = (event) => {
console.error(
"File history migration failed:",
(event.target as IDBRequest).error,
);
// Same reasoning as the per-record catch above: abort the upgrade so the
// schema doesn't get marked as v4 with rows still on the v3 shape.
const err = (event.target as IDBRequest).error;
console.error("Files-store migration cursor failed:", err);
try {
store.transaction.abort();
} catch {
// Already aborted - ignore.
}
};
}
@@ -305,7 +324,7 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = {
FILES: {
name: "stirling-pdf-files",
version: 3,
version: 4,
stores: [
{
name: "files",
@@ -316,6 +335,20 @@ export const DATABASE_CONFIGS = {
{ name: "originalFileId", keyPath: "originalFileId", unique: false },
{ name: "parentFileId", keyPath: "parentFileId", unique: false },
{ name: "versionNumber", keyPath: "versionNumber", unique: false },
{ name: "folderId", keyPath: "folderId", unique: false },
],
},
{
name: "folders",
keyPath: "id",
indexes: [
{
name: "parentFolderId",
keyPath: "parentFolderId",
unique: false,
},
{ name: "name", keyPath: "name", unique: false },
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
],
@@ -37,28 +37,30 @@ export async function waitForModalClose(
}
/**
* Upload one or more files through the workbench's "Files" modal. The modal
* auto-closes once a file is selected; we wait for the overlay to vanish so
* the caller can interact with the page immediately afterwards.
* Upload one or more files through the FileSidebar's "Open from computer"
* action. The button is always rendered (collapsed or expanded sidebar) and
* triggers the hidden `data-testid="file-input"` native picker directly -
* there is no modal to wait for under the post-refactor design.
*
* Pass `awaitClose: false` when the spec is testing a flow that keeps the
* modal open after upload (e.g. encrypted-PDF unlock the unlock modal
* appears on top before the files modal closes).
* `setInputFiles` doesn't await the input's async onChange (which writes to
* IndexedDB via `addFiles`), so without a sync point a caller that follows
* with `page.goto()` can race the IDB flush. Wait for the workbench to
* pick up the upload (the FileSidebar renders the added file in its scroll
* list once `addFiles` resolves and IDB has been written).
*/
export async function uploadFiles(
page: Page,
filePaths: string | string[],
opts: { awaitClose?: boolean } = {},
): Promise<void> {
const { awaitClose = true } = opts;
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
await page.getByTestId("files-button").click();
await waitForModalOpen(page);
await page
.locator('[data-testid="file-input"]')
.setInputFiles(filePaths as string | string[]);
if (awaitClose) {
await waitForModalClose(page);
}
await page.locator('[data-testid="file-input"]').setInputFiles(paths);
// Sync point: wait until at least one file lands in the sidebar's file
// list. The list only renders once `addFiles` has resolved (which awaits
// the IDB write). Use first() so multi-file uploads pass too.
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
timeout: 10_000,
});
}
/**
@@ -3,7 +3,6 @@ import { loginAndSetup } from "@app/tests/helpers/login";
import {
switchToEditorIfViewerMode,
runToolAndWaitForReview,
waitForModalOpen,
waitForModalClose,
} from "@app/tests/helpers/ui-helpers";
import path from "path";
@@ -33,19 +32,18 @@ test.describe("Encrypted PDF: unlock then merge", () => {
await page.waitForLoadState("domcontentloaded");
await page.getByTestId("files-button").click();
await waitForModalOpen(page);
await page
.locator('[data-testid="file-input"]')
.setInputFiles([ENCRYPTED_PDF, SAMPLE_PDF]);
// Encrypted unlock modal appears (the files modal may still be closing)
// Encrypted unlock modal appears once decryption is attempted.
const passwordInput = page.getByPlaceholder(/password/i).first();
if (
!(await passwordInput.isVisible({ timeout: 10_000 }).catch(() => false))
) {
test.skip(
true,
"Encrypted unlock modal not surfaced fixture may not match this build",
"Encrypted unlock modal not surfaced - fixture may not match this build",
);
return;
}
@@ -1,7 +1,7 @@
/**
* End-to-End Tests for Convert Tool
*
* All backend API calls are mocked via page.route() no real backend required.
* All backend API calls are mocked via page.route() - no real backend required.
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
@@ -23,22 +23,14 @@ async function dismissTourTooltip(page: Page) {
}
// ---------------------------------------------------------------------------
// Helper: upload a file through the Files modal
// Uses the HiddenFileInput (data-testid="file-input") which has the correct
// onChange handler. Waits for the modal to auto-close after upload.
// Helper: upload a file via the FileSidebar's "Open from computer" action.
// The button now triggers the native OS picker directly - no modal - and
// the hidden `data-testid="file-input"` accepts `setInputFiles` in either
// sidebar state.
// ---------------------------------------------------------------------------
async function uploadFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
// Modal auto-closes after file is selected
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10000,
});
}
// ---------------------------------------------------------------------------
@@ -150,12 +142,12 @@ test.describe("Convert Tool", () => {
await uploadFile(page, SAMPLE_PDF);
await navigateToConvert(page);
// Before selecting TO format button visible but disabled
// Before selecting TO format - button visible but disabled
const convertBtn = page.getByTestId("convert-button");
await expect(convertBtn).toBeVisible({ timeout: 3000 });
await expect(convertBtn).toBeDisabled();
// After selecting PNG as TO format button enabled
// After selecting PNG as TO format - button enabled
await selectToFormat(page, "png");
await expect(convertBtn).toBeEnabled({ timeout: 3000 });
});
@@ -2,7 +2,7 @@
* End-to-End Tests for Encrypted PDF Password Prompting
*
* Tests the EncryptedPdfUnlockModal flow when uploading password-protected PDFs.
* All backend API calls are mocked via page.route() no real backend required.
* All backend API calls are mocked via page.route() - no real backend required.
*
* Coverage trimmed to 5 high-value cases:
* 1. Modal renders with the expected title/inputs/buttons.
@@ -14,7 +14,7 @@
*
* Removed previously: input-disabled-when-empty, input-enabled-after-fill,
* skip-button-closes, normal-PDF-doesn't-prompt, single-file-hides-use-for-all,
* unlock-all-wrong-password all transitively covered or low-value.
* unlock-all-wrong-password - all transitively covered or low-value.
*/
import { test, expect, type Page } from "@playwright/test";
@@ -64,10 +64,7 @@ function mockRemovePasswordWrongPassword(page: Page) {
async function uploadEncryptedFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
// No modal flow - `files-button` triggers the native picker directly.
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
}
@@ -157,10 +154,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
await mockRemovePasswordSuccess(page);
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
// No modal flow - `files-button` triggers the native picker directly.
await page.locator('[data-testid="file-input"]').setInputFiles([
{
name: "encrypted-a.pdf",
@@ -179,7 +173,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
// detected as encrypted. PDF.js encryption probing runs per-file and
// can lag the modal opening (which fires as soon as the first file
// surfaces a password prompt). A 10s timeout was occasionally too tight
// on heavily-loaded CI runners bump to 20s.
// on heavily-loaded CI runners - bump to 20s.
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
await expect(unlockAllBtn).toBeVisible({ timeout: 20000 });
@@ -16,19 +16,18 @@ test.describe("File state persists across tool navigation", () => {
}) => {
await uploadFiles(page, SAMPLE_PDF);
// Sanity: the file picker now lists the upload
await page.getByTestId("files-button").click();
// Sanity: My Files page lists the upload
await page.getByTestId("my-files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
// Navigate to /split
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
// Re-open the files modal — sample.pdf must still be there
await page.getByTestId("files-button").click();
// Re-open My Files - sample.pdf must still be there (persisted across tools)
await page.getByTestId("my-files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
@@ -0,0 +1,554 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "node:path";
/** Screenshot review of /files surfaces; dumps PNGs to screenshots/files-page. */
interface SeedFile {
id: string;
name: string;
remoteStorageId: number | null;
folderId?: string | null;
}
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
await page.addInitScript((records) => {
const open = window.indexedDB.open("stirling-pdf-files", 4);
open.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create both `files` and `folders` stores on this DB.
if (!db.objectStoreNames.contains("files")) {
const store = db.createObjectStore("files", { keyPath: "id" });
store.createIndex("name", "name", { unique: false });
store.createIndex("folderId", "folderId", { unique: false });
store.createIndex("originalFileId", "originalFileId", {
unique: false,
});
}
if (!db.objectStoreNames.contains("folders")) {
const fStore = db.createObjectStore("folders", { keyPath: "id" });
fStore.createIndex("parentFolderId", "parentFolderId", {
unique: false,
});
fStore.createIndex("name", "name", { unique: false });
}
};
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction("files", "readwrite");
const store = tx.objectStore("files");
const now = Date.now();
for (const f of records) {
store.put({
id: f.id,
fileId: f.id,
quickKey: f.id,
name: f.name,
type: "application/pdf",
size: 1024,
lastModified: now,
createdAt: now,
data: new ArrayBuffer(8),
thumbnail: null,
isLeaf: true,
versionNumber: 1,
originalFileId: f.id,
parentFileId: null,
toolHistory: [],
folderId: f.folderId ?? null,
remoteStorageId: f.remoteStorageId,
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
remoteAccessRole: f.remoteStorageId ? "owner" : null,
remoteSharedViaLink: false,
remoteHasShareLinks: false,
remoteShareToken: null,
});
}
};
}, files);
}
async function stubStorageApis(
page: Page,
opts: { storageEnabled?: boolean } = {},
): Promise<void> {
const { storageEnabled = true } = opts;
const configPayload = {
appVersion: "test",
storageEnabled,
storageSharingEnabled: false,
storageShareLinksEnabled: false,
};
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/storage/folders", (route: Route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/storage/**", (route: Route) =>
route.fulfill({ json: [] }),
);
}
const SCREENSHOTS_DIR = path.resolve(
process.cwd(),
"screenshots",
"files-page",
);
function shotPath(name: string): string {
return path.join(SCREENSHOTS_DIR, `${name}.png`);
}
async function settle(page: Page, ms = 350): Promise<void> {
// Let Mantine portal transitions settle.
await page.waitForTimeout(ms);
}
test.describe("Files page screenshots", () => {
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
test("01_empty_state_ctas", async ({ page }) => {
await stubStorageApis(page);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("01_empty_state_ctas") });
});
test("02_empty_state_storage_off", async ({ page }) => {
await stubStorageApis(page, { storageEnabled: false });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("02_empty_state_storage_off") });
});
test("03_subtoolbar_with_files", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("03_subtoolbar_with_files") });
});
test("04_kebab_save_to_server_local", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await expect(
page.getByRole("menuitem", { name: /^Save to server$/i }),
).toBeVisible();
await settle(page);
await page.screenshot({ path: shotPath("04_kebab_save_to_server_local") });
});
test("05_kebab_no_save_to_server_cloud", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "cloud-c.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await settle(page);
await page.screenshot({
path: shotPath("05_kebab_no_save_to_server_cloud"),
});
});
test("06_details_panel_save_to_server", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" })
.click();
await expect(page.locator(".files-page-details")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("06_details_panel_save_to_server"),
});
});
test("07_move_dialog_collapsed", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await expect(
page.getByRole("dialog", { name: /Move to folder/i }),
).toBeVisible();
await settle(page);
await page.screenshot({ path: shotPath("07_move_dialog_collapsed") });
});
test("08_move_dialog_create_folder_expanded", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await expect(
page.getByRole("dialog", { name: /Move to folder/i }),
).toBeVisible();
await page.getByRole("button", { name: /Create new folder/i }).click();
await expect(
page.getByRole("textbox", { name: /New folder name/i }),
).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("08_move_dialog_create_folder_expanded"),
});
});
test("09_subtoolbar_narrow_viewport", async ({ page, browserName }) => {
test.skip(browserName !== "chromium", "viewport-resize spec");
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.setViewportSize({ width: 900, height: 700 });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("09_subtoolbar_narrow_viewport") });
});
test("08b_move_dialog_after_create_folder", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.route(
"**/api/v1/storage/folders",
async (route: Route) => {
if (route.request().method() === "POST") {
// FolderId must be a UUID; timestamps must be ISO strings.
await route.fulfill({
json: {
id: "11111111-2222-4333-8444-555555555555",
name: "Reports",
parentFolderId: null,
color: null,
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
});
return;
}
await route.fulfill({ json: [] });
},
{ times: 5 },
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await page.getByRole("button", { name: /Create new folder/i }).click();
await page
.getByRole("textbox", { name: /New folder name/i })
.fill("Reports");
await page.getByRole("button", { name: /^Create$/i }).click();
// Inline row collapses back; create succeeded.
await expect(
page.getByRole("button", { name: /Create new folder/i }),
).toBeVisible({ timeout: 3_000 });
await settle(page);
await page.screenshot({
path: shotPath("08b_move_dialog_after_create_folder"),
});
});
// ─── Dark mode pass ─────────────────────────────────────────────────────
async function enableDarkMode(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
test("11_dark_empty_state_ctas", async ({ page }) => {
await enableDarkMode(page);
await stubStorageApis(page);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("11_dark_empty_state_ctas") });
});
test("12_dark_subtoolbar_with_files", async ({ page }) => {
await enableDarkMode(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("12_dark_subtoolbar_with_files") });
});
test("13_dark_move_dialog_create_folder", async ({ page }) => {
await enableDarkMode(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await page.getByRole("button", { name: /Create new folder/i }).click();
await expect(
page.getByRole("textbox", { name: /New folder name/i }),
).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("13_dark_move_dialog_create_folder"),
});
});
// ─── RTL pass ────────────────────────────────────────────────────────────
async function enableRtl(page: Page): Promise<void> {
// Seed language + dir before first paint.
await page.addInitScript(() => {
localStorage.setItem("i18nextLng", "ar-AR");
localStorage.setItem("stirling-language", "ar-AR");
localStorage.setItem("stirling-language-source", "user");
document.documentElement.setAttribute("dir", "rtl");
document.documentElement.setAttribute("lang", "ar-AR");
});
}
test("15_rtl_empty_state_ctas", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("15_rtl_empty_state_ctas") });
});
test("16_rtl_subtoolbar_with_files", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("16_rtl_subtoolbar_with_files") });
});
test("17_rtl_move_dialog_create_folder", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await page.getByRole("button", { name: /Create new folder/i }).click();
await expect(
page.getByRole("textbox", { name: /New folder name/i }),
).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("17_rtl_move_dialog_create_folder"),
});
});
test("18_rtl_details_panel_save_to_server", async ({ page }) => {
await enableRtl(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" })
.click();
await expect(page.locator(".files-page-details")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("18_rtl_details_panel_save_to_server"),
});
});
test("14_dark_details_panel_save_to_server", async ({ page }) => {
await enableDarkMode(page);
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "alpha.pdf" })
.click();
await expect(page.locator(".files-page-details")).toBeVisible();
await settle(page);
await page.screenshot({
path: shotPath("14_dark_details_panel_save_to_server"),
});
});
test("19_delete_folder_dialog", async ({ page }) => {
const FOLDER_ID = "22222222-2222-4333-8444-555555555555";
await stubStorageApis(page);
// Seed a file inside the Reports folder so the checkbox appears.
await seedFiles(page, [
{
id: "alpha",
name: "alpha.pdf",
remoteStorageId: 9001,
folderId: FOLDER_ID,
},
{
id: "bravo",
name: "bravo.pdf",
remoteStorageId: 9002,
folderId: FOLDER_ID,
},
]);
await page.route("**/api/v1/storage/folders", async (route: Route) => {
await route.fulfill({
json: [
{
id: FOLDER_ID,
name: "Reports",
parentFolderId: null,
color: null,
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
});
});
await page.goto("/files", { waitUntil: "domcontentloaded" });
// Wait for the Reports folder card or list row.
await expect(page.getByText("Reports").first()).toBeVisible({
timeout: 5_000,
});
// Open the kebab on the folder card.
const folderCard = page
.locator(".files-page-card.is-folder")
.filter({ hasText: "Reports" })
.first();
await folderCard.getByRole("button", { name: /Folder actions/i }).click();
await page.getByRole("menuitem", { name: /Delete folder/i }).click();
await expect(
page.getByRole("dialog", { name: /Delete folder\?/i }),
).toBeVisible({ timeout: 3_000 });
await settle(page);
await page.screenshot({ path: shotPath("19_delete_folder_dialog") });
});
test("10_subtoolbar_phone_hidden", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await page.setViewportSize({ width: 500, height: 900 });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
await settle(page);
await page.screenshot({ path: shotPath("10_subtoolbar_phone_hidden") });
});
});
@@ -0,0 +1,819 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
/** Stubbed coverage for the /files page UI invariants. */
interface SeedFile {
id: string;
name: string;
remoteStorageId: number | null;
versionNumber?: number;
toolHistory?: Array<{ toolId: string; timestamp: number }>;
}
/** Seed IDB + register the cloud entries with the server stub. */
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
// Build the server-side view from the cloud entries so reconcileServerFiles
// sees them as still-existing on the server (otherwise they get detached).
const serverFiles = files
.filter((f) => f.remoteStorageId != null)
.map((f) => ({
id: f.remoteStorageId,
fileName: f.name,
contentType: "application/pdf",
sizeBytes: 1024,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
owner: "testuser",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [],
filePurpose: "generic",
folderId: null,
}));
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({ json: serverFiles }),
);
await page.addInitScript((records) => {
const open = window.indexedDB.open("stirling-pdf-files", 4);
open.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create both `files` and `folders` stores on this DB.
if (!db.objectStoreNames.contains("files")) {
const store = db.createObjectStore("files", { keyPath: "id" });
store.createIndex("name", "name", { unique: false });
store.createIndex("folderId", "folderId", { unique: false });
store.createIndex("originalFileId", "originalFileId", {
unique: false,
});
}
if (!db.objectStoreNames.contains("folders")) {
const fStore = db.createObjectStore("folders", { keyPath: "id" });
fStore.createIndex("parentFolderId", "parentFolderId", {
unique: false,
});
fStore.createIndex("name", "name", { unique: false });
}
};
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction("files", "readwrite");
const store = tx.objectStore("files");
const now = Date.now();
for (const f of records) {
store.put({
id: f.id,
fileId: f.id,
quickKey: f.id,
name: f.name,
type: "application/pdf",
size: 1024,
lastModified: now,
createdAt: now,
// Placeholder; opening would need real bytes.
data: new ArrayBuffer(8),
thumbnail: null,
isLeaf: true,
versionNumber: f.versionNumber ?? 1,
originalFileId: f.id,
parentFileId: null,
toolHistory: f.toolHistory ?? [],
folderId: null,
remoteStorageId: f.remoteStorageId,
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
remoteAccessRole: f.remoteStorageId ? "owner" : null,
remoteSharedViaLink: false,
remoteHasShareLinks: false,
remoteShareToken: null,
});
}
};
}, files);
}
/** Stub the storage + config endpoints hit on mount. */
async function stubStorageApis(
page: Page,
opts: { storageEnabled?: boolean; sharingEnabled?: boolean } = {},
): Promise<void> {
const { storageEnabled = true, sharingEnabled = false } = opts;
// No enableLogin; setting it would trigger the auth redirect.
const configPayload = {
appVersion: "test",
storageEnabled,
storageSharingEnabled: sharingEnabled,
storageShareLinksEnabled: sharingEnabled,
};
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/config", (route: Route) =>
route.fulfill({ json: configPayload }),
);
await page.route("**/api/v1/storage/folders", (route: Route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/storage/**", (route: Route) =>
route.fulfill({ json: [] }),
);
}
/** Navigate to /files and wait for at least one seeded card. */
async function gotoFilesPage(page: Page): Promise<void> {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-card").first()).toBeVisible({
timeout: 5_000,
});
}
test.describe("Files page", () => {
test.describe("Selection model", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
{ id: "charlie", name: "charlie.pdf", remoteStorageId: null },
{ id: "delta", name: "delta.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("plain click selects one file (single-select replaces)", async ({
page,
}) => {
await gotoFilesPage(page);
const cards = page.locator(".files-page-card:not(.is-folder)");
await cards.nth(0).click();
await expect(cards.locator(".is-selected")).toHaveCount(0);
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
// Plain-clicking a different file replaces the selection.
await cards.nth(1).click();
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
});
test("ctrl+click toggles into multi-select mode (sticky)", async ({
page,
}) => {
await gotoFilesPage(page);
const cards = page.locator(".files-page-card:not(.is-folder)");
await cards.nth(0).click();
await cards.nth(1).click({ modifiers: ["Control"] });
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
// In multi-select (2+), plain-click ADDS instead of replacing.
await cards.nth(2).click();
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(3);
// Plain-click an already-selected file in multi-mode removes it.
await cards.nth(0).click();
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
});
test("checkboxes hidden in single-select, visible in multi-select", async ({
page,
}) => {
await gotoFilesPage(page);
const cards = page.locator(".files-page-card:not(.is-folder)");
// 0 selected: no checkboxes anywhere on file cards.
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
// 1 selected: still no checkbox (highlight border is the indicator).
await cards.nth(0).click();
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
// 2+ selected: checkboxes appear on every file card.
await cards.nth(1).click({ modifiers: ["Control"] });
await expect(
page.locator(".files-page-card-selector").first(),
).toBeVisible();
});
test("Select all tooltip explains Ctrl/Shift shortcuts", async ({
page,
}) => {
await gotoFilesPage(page);
// Tooltip is the discovery point for Ctrl/Shift multi-select.
const selectAll = page.getByRole("button", { name: /^Select all$/i });
await selectAll.hover();
await expect(
page.getByText(/hold Ctrl.*Cmd.*Shift to select a range/i),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("Bulk action button visibility", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
{ id: "local-b", name: "local-b.pdf", remoteStorageId: null },
{ id: "cloud-a", name: "cloud-a.pdf", remoteStorageId: 1001 },
]);
});
test.use({ autoGoto: false });
test("Save to server hidden when nothing selected", async ({ page }) => {
await gotoFilesPage(page);
await expect(
page.getByRole("button", { name: /^Save to server$/i }),
).toHaveCount(0);
});
test("Save to server visible when local file selected", async ({
page,
}) => {
await gotoFilesPage(page);
// Click the local-a card.
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" })
.click();
// Two entry points share the name; use .first() for strict mode.
await expect(
page.getByRole("button", { name: /^Save to server$/i }).first(),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^Save to server$/i }),
).toHaveCount(2);
});
test("Save to server hidden when ONLY cloud files selected", async ({
page,
}) => {
await gotoFilesPage(page);
// Cloud-only selection - nothing to save (already on server).
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "cloud-a.pdf" })
.click();
await expect(
page.getByRole("button", { name: /^Save to server$/i }),
).toHaveCount(0);
});
test("Per-file kebab has Save to server item for local file", async ({
page,
}) => {
await gotoFilesPage(page);
// Open the kebab without first selecting.
const localCard = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" });
await localCard.getByRole("button", { name: /File actions/i }).click();
await expect(
page.getByRole("menuitem", { name: /^Save to server$/i }),
).toBeVisible();
});
test("Per-file kebab hides Save to server for cloud file", async ({
page,
}) => {
await gotoFilesPage(page);
// Cloud file kebab omits Save to server.
const cloudCard = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "cloud-a.pdf" });
await cloudCard.getByRole("button", { name: /File actions/i }).click();
await expect(
page.getByRole("menuitem", { name: /^Save to server$/i }),
).toHaveCount(0);
});
});
test.describe("Upload behaviour", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "seed", name: "seed.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("upload on /files page doesn't navigate the user away", async ({
page,
}) => {
await gotoFilesPage(page);
// Write to the hidden file input directly.
const tinyPdf = Buffer.from("%PDF-1.4\n%%EOF", "utf8");
const input = page.locator('input[data-testid="file-input"]').first();
if ((await input.count()) === 0) {
test.skip(
true,
"No file-input testid on this build - upload entry-point selector drifted",
);
}
await input.setInputFiles({
name: "upload-test.pdf",
mimeType: "application/pdf",
buffer: tinyPdf,
});
// Upload must leave the user on /files.
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/files/);
});
});
test.describe("Already-active file handling", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "active-test", name: "active-test.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("Add to workspace on already-active file navigates without crash", async ({
page,
}) => {
await gotoFilesPage(page);
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "active-test.pdf" });
await card.click();
// First Add to workspace; routes to viewer.
await page
.getByRole("button", { name: /Add to workspace/i })
.first()
.click();
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
// Re-add the now-active file; activation branches on requested stubs.
await page.goto("/files", { waitUntil: "domcontentloaded" });
const card2 = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "active-test.pdf" });
await expect(card2).toBeVisible({ timeout: 5_000 });
await card2.click();
await page
.getByRole("button", { name: /Add to workspace/i })
.first()
.click();
// Should still navigate away, NOT throw and leave us stuck on /files.
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
});
});
test.describe("Drag-and-drop wiring", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "drag-test", name: "drag-test.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("card thumbnail <img> is not natively draggable", async ({ page }) => {
// draggable={false} keeps the card's onDragStart as drag authority.
await gotoFilesPage(page);
const thumbImg = page.locator(".files-page-card-thumb img").first();
if ((await thumbImg.count()) === 0) {
test.skip(
true,
"Seeded files have no thumbnailUrl so the <img> branch isn't rendered - drag-hijack regression can't surface",
);
}
await expect(thumbImg).toHaveAttribute("draggable", "false");
});
});
test.describe("Mobile details drawer", () => {
test.use({
autoGoto: false,
viewport: { width: 500, height: 900 },
});
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "phone-a", name: "phone-a.pdf", remoteStorageId: null },
{ id: "phone-b", name: "phone-b.pdf", remoteStorageId: null },
]);
});
test("drawer does NOT auto-open on file selection", async ({ page }) => {
// Drawer is button-triggered only.
await gotoFilesPage(page);
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "phone-a.pdf" })
.click();
// No drawer overlay should be present.
await expect(page.locator(".mantine-Drawer-content")).toHaveCount(0);
});
test("Show details button opens drawer with file info", async ({
page,
}) => {
await gotoFilesPage(page);
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "phone-a.pdf" })
.click();
await page.getByRole("button", { name: /Show details/i }).click();
// Drawer opens, file name shown inside it.
await expect(page.locator(".mantine-Drawer-content")).toBeVisible({
timeout: 3_000,
});
await expect(
page.locator(".mantine-Drawer-content").getByText("phone-a.pdf"),
).toBeVisible();
});
test("multi-select still works while drawer is closed", async ({
page,
}) => {
await gotoFilesPage(page);
const cards = page.locator(".files-page-card:not(.is-folder)");
await cards.nth(0).click();
// Drawer stays closed so the second click reaches the card.
await cards.nth(1).click({ modifiers: ["Control"] });
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
});
});
test.describe("Empty-state CTAs", () => {
test.use({ autoGoto: false });
test("renders Upload + Create folder CTAs when grid is empty", async ({
page,
}) => {
await stubStorageApis(page);
// No seedFiles - grid is empty so EmptyState renders.
await page.goto("/files", { waitUntil: "domcontentloaded" });
// Wait for the empty state itself rather than card visibility -
// gotoFilesPage's card-visibility wait would time out here.
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
// Both CTAs centered in the grid area where the eye lands.
await expect(
page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Upload files/i }),
).toBeVisible();
await expect(
page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i }),
).toBeVisible();
});
test("Create folder CTA disabled when storage isn't reachable", async ({
page,
}) => {
// Storage disabled - the New folder action is gated and the CTA
// should mirror that gating with a disabled state.
await stubStorageApis(page, { storageEnabled: false });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
timeout: 5_000,
});
const createCta = page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i });
await expect(createCta).toBeVisible();
await expect(createCta).toBeDisabled();
});
});
test.describe("Move dialog inline create-folder", () => {
test.use({ autoGoto: false });
test("Move dialog shows Create new folder affordance", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "to-move", name: "to-move.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
// Open the move dialog via the per-file kebab.
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "to-move.pdf" });
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Move to/i }).click();
await expect(
page.getByRole("button", { name: /Create new folder/i }),
).toBeVisible();
});
});
test.describe("Side-rail integration with /files", () => {
test.use({ autoGoto: false });
test("Rail Search focuses the central search field, no navigation", async ({
page,
}) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
// Click the search row in the rail.
await page.locator(".file-sidebar-search-row").click();
// The central search input should be focused.
const focused = await page.evaluate(
() => document.activeElement?.getAttribute("aria-label") ?? "",
);
expect(focused).toMatch(/Search/i);
// And we must still be on /files (i.e. didn't navigate home).
await expect(page).toHaveURL(/\/files/);
});
test("Rail New folder button visible on /files", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
// The extra action is the only thing with this testid.
await expect(
page.locator('[data-testid="files-rail-new-folder"]'),
).toBeVisible();
});
});
test.describe("Server file sync", () => {
test.use({ autoGoto: false });
test("Server-only file downloads bytes when opened", async ({ page }) => {
await stubStorageApis(page);
const REMOTE_ID = 9001;
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({
json: [
{
id: REMOTE_ID,
fileName: "cross-browser.pdf",
contentType: "application/pdf",
sizeBytes: 4096,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
owner: "testuser",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [],
filePurpose: "generic",
folderId: null,
},
],
}),
);
let downloadHit = false;
await page.route(
`**/api/v1/storage/files/${REMOTE_ID}/download`,
(route: Route) => {
downloadHit = true;
route.fulfill({
status: 200,
headers: {
"content-type": "application/pdf",
"content-disposition": 'attachment; filename="cross-browser.pdf"',
},
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "cross-browser.pdf" });
await expect(card).toBeVisible({ timeout: 5_000 });
// Open via Add to workspace (kebab > Add to workspace).
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
// The materializer should have hit the download endpoint and
// routed the user to the viewer (/).
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
timeout: 5_000,
});
expect(downloadHit).toBe(true);
});
test("Shared-link file appears in /files and materializes on open", async ({
page,
}) => {
await stubStorageApis(page, { sharingEnabled: true });
const SHARE_TOKEN = "tok-abc-123";
// Owner-side listing has no entry for the shared file.
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({ json: [] }),
);
await page.route(
"**/api/v1/storage/share-links/accessed",
(route: Route) =>
route.fulfill({
json: [
{
shareToken: SHARE_TOKEN,
fileId: 4242,
fileName: "shared-report.pdf",
owner: "alice",
ownedByCurrentUser: false,
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
},
],
}),
);
let shareDownloadHit = false;
await page.route(
`**/api/v1/storage/share-links/${SHARE_TOKEN}`,
(route: Route) => {
shareDownloadHit = true;
route.fulfill({
status: 200,
headers: {
"content-type": "application/pdf",
"content-disposition": 'attachment; filename="shared-report.pdf"',
},
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
});
},
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
const card = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "shared-report.pdf" });
await expect(card).toBeVisible({ timeout: 5_000 });
// Open the card and confirm the share-link download endpoint fires.
await card.getByRole("button", { name: /File actions/i }).click();
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
timeout: 5_000,
});
expect(shareDownloadHit).toBe(true);
});
test("Server-only files appear in /files on a fresh browser", async ({
page,
}) => {
await stubStorageApis(page);
// No local IDB seed. Override the GET /api/v1/storage/files route
// to return a file that the server knows about. The /files grid
// should pull this in via the new sync path.
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({
json: [
{
id: 9001,
fileName: "cross-browser.pdf",
contentType: "application/pdf",
sizeBytes: 4096,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
owner: "testuser",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [],
filePurpose: "generic",
folderId: null,
},
],
}),
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
// The file lands as a synthesised server stub.
await expect(
page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "cross-browser.pdf" }),
).toBeVisible({ timeout: 5_000 });
});
test("Shared-by-me tab lists only files I own with share links", async ({
page,
}) => {
await stubStorageApis(page, { sharingEnabled: true });
// Three server files: one shared via link (owned by me), one shared
// with users (owned by me), one plain mine, and one owned by someone else.
await page.route("**/api/v1/storage/files", (route: Route) =>
route.fulfill({
json: [
{
id: 1,
fileName: "link-shared.pdf",
contentType: "application/pdf",
sizeBytes: 100,
createdAt: new Date().toISOString(),
owner: "admin",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [{ token: "tok1" }],
sharedUsers: [],
filePurpose: "generic",
folderId: null,
},
{
id: 2,
fileName: "user-shared.pdf",
contentType: "application/pdf",
sizeBytes: 100,
createdAt: new Date().toISOString(),
owner: "admin",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [],
sharedUsers: [{ username: "bob" }],
filePurpose: "generic",
folderId: null,
},
{
id: 3,
fileName: "plain-mine.pdf",
contentType: "application/pdf",
sizeBytes: 100,
createdAt: new Date().toISOString(),
owner: "admin",
ownedByCurrentUser: true,
accessRole: "owner",
shareLinks: [],
sharedUsers: [],
filePurpose: "generic",
folderId: null,
},
{
id: 4,
fileName: "from-someone-else.pdf",
contentType: "application/pdf",
sizeBytes: 100,
createdAt: new Date().toISOString(),
owner: "alice",
ownedByCurrentUser: false,
accessRole: "viewer",
shareLinks: [],
sharedUsers: [],
filePurpose: "generic",
folderId: null,
},
],
}),
);
await page.goto("/files", { waitUntil: "domcontentloaded" });
// Wait for the 4 cards to land via server sync.
await expect(
page.locator(".files-page-card:not(.is-folder)"),
).toHaveCount(4, { timeout: 5_000 });
// "Shared by me" -> only link-shared.pdf
await page.locator("#filesPage-tab-sharedByMe").click();
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
// "I'm sharing" -> only user-shared.pdf
await page.locator("#filesPage-tab-imSharing").click();
const imSharingCards = page.locator(".files-page-card:not(.is-folder)");
await expect(imSharingCards).toHaveCount(1, { timeout: 3_000 });
await expect(imSharingCards.first()).toContainText("user-shared.pdf");
// "Shared with me" -> only from-someone-else.pdf
await page.locator("#filesPage-tab-shared").click();
const sharedWithMeCards = page.locator(
".files-page-card:not(.is-folder)",
);
await expect(sharedWithMeCards).toHaveCount(1, { timeout: 3_000 });
await expect(sharedWithMeCards.first()).toContainText(
"from-someone-else.pdf",
);
});
});
test.describe("Folder tree panel resize", () => {
test.use({ autoGoto: false });
test("Resize handle is present and keyboard-adjustable", async ({
page,
}) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
const handle = page.locator(".folder-tree-panel-resizer").first();
await expect(handle).toBeVisible();
const before = await page.evaluate(() => {
const el = document.querySelector(
".folder-tree-panel[data-active='true']",
) as HTMLElement | null;
return el?.getBoundingClientRect().width ?? 0;
});
await handle.focus();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
const after = await page.evaluate(() => {
const el = document.querySelector(
".folder-tree-panel[data-active='true']",
) as HTMLElement | null;
return el?.getBoundingClientRect().width ?? 0;
});
// Four 8px steps = +32px.
expect(after).toBeGreaterThanOrEqual(before + 24);
});
});
});
@@ -9,24 +9,17 @@ const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
* and accepts a query catches the most damaging regression (search bar
* disappears when reader refactors).
*/
test.describe("Reader in-document text search", () => {
test.describe("Reader - in-document text search", () => {
test("search input is reachable from the reader and accepts a query", async ({
page,
}) => {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
// Upload a PDF first so the reader has content
// Upload a PDF first so the reader has content. `files-button` now
// triggers the native picker directly - no modal flow involved.
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5_000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10_000,
});
// The WorkbenchBar exposes a "Search PDF" button (aria-label="Search PDF")
// that opens a Popover with the in-document search input.
@@ -121,6 +121,43 @@ test.describe("Settings dialog", () => {
await restored.click();
});
test("close returns to origin URL even after switching tabs (no history pile-up)", async ({
page,
}) => {
// Land on / first so the originating URL is unambiguous.
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 5_000 });
const originPath = new URL(page.url()).pathname;
const dialog = await openSettings(page);
// Click the same visible nav 3 times. In the buggy code each click
// pushed a fresh history entry, so close-by-back popped only the
// most recent tab change. The fix uses PUSH for the first nav (when
// not yet in /settings/*) and REPLACE for subsequent navs, so the
// origin URL stays at history depth 1 regardless of how many tabs
// the user clicks through.
const generalNav = dialog
.locator('[data-tour="admin-general-nav"]')
.first();
await expect(generalNav).toBeVisible({ timeout: 5_000 });
for (let i = 0; i < 3; i++) {
await generalNav.click();
}
// Wait for the URL to settle on /settings/general before closing so
// we're not racing the in-modal nav under parallel-worker load.
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
await closeSettings(page);
// Wait for the URL pathname to settle back to origin. Match only
// pathname so trailing ?query or #hash don't trip the assertion.
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 5_000 })
.toBe(originPath);
});
test("config sub-sections (System / Features / Endpoints / API Keys) are reachable when present", async ({
page,
}) => {
@@ -20,9 +20,9 @@ import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
// ---------------------------------------------------------------------------
// 15.1 Static layout always visible on the main page
// 15.1 Static layout - always visible on the main page
// ---------------------------------------------------------------------------
test.describe("15.1 Tour selectors static layout", () => {
test.describe("15.1 Tour selectors - static layout", () => {
test("tool-panel is present", async ({ page }) => {
await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({
timeout: 10_000,
@@ -55,7 +55,7 @@ test.describe("15.1 Tour selectors — static layout", () => {
// help-button: not yet implemented in the redesigned FileSidebar layout.
// Re-enable once a tours/help entry point is added to the new UI.
test.skip("help-button is present TODO: add to new sidebar layout", async ({
test.skip("help-button is present - TODO: add to new sidebar layout", async ({
page,
}) => {
await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
@@ -65,10 +65,18 @@ test.describe("15.1 Tour selectors — static layout", () => {
});
// ---------------------------------------------------------------------------
// 15.2 Tour selectors files modal
// 15.2 Tour selectors - files modal
// ---------------------------------------------------------------------------
test.describe("15.2 Tour selectors — files modal", () => {
test("file-sources is present when files modal is open", async ({ page }) => {
// SKIPPED post-refactor: the FilesModal is no longer reachable from the
// FileSidebar's `files-button` (which now triggers the native OS picker
// directly). The modal is still rendered for AddFileCard /
// PageEditorFileDropdown / LandingActions paths, but none of those have a
// stable testid yet to drive from a spec. Re-enable once a `data-testid`
// is added to one of the modal-opening entry points.
test.describe("15.2 Tour selectors - files modal", () => {
test.skip("file-sources is present when files modal is open", async ({
page,
}) => {
await page.getByTestId("files-button").click();
await expect(
page.locator('[data-tour="file-sources"]').first(),
@@ -78,9 +86,9 @@ test.describe("15.2 Tour selectors — files modal", () => {
});
// ---------------------------------------------------------------------------
// 15.3 Tour selectors workbench elements (require a loaded file)
// 15.3 Tour selectors - workbench elements (require a loaded file)
// ---------------------------------------------------------------------------
test.describe("15.3 Tour selectors workbench with file", () => {
test.describe("15.3 Tour selectors - workbench with file", () => {
test.beforeEach(async ({ page }) => {
await uploadFiles(page, SAMPLE_PDF);
});
@@ -99,10 +107,10 @@ test.describe("15.3 Tour selectors — workbench with file", () => {
});
// ---------------------------------------------------------------------------
// 15.4 Tour selectors active files view (file-card-checkbox, file-card-pin)
// 15.4 Tour selectors - active files view (file-card-checkbox, file-card-pin)
// Two files → app auto-navigates to fileEditor (active files) mode.
// ---------------------------------------------------------------------------
test.describe("15.4 Tour selectors active files view", () => {
test.describe("15.4 Tour selectors - active files view", () => {
test.beforeEach(async ({ page }) => {
// Two files → getStartupNavigationAction returns workbench:"fileEditor"
await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]);
@@ -132,9 +140,9 @@ test.describe("15.4 Tour selectors — active files view", () => {
});
// ---------------------------------------------------------------------------
// 15.5 Tour selectors crop tool (crop-settings, run-button)
// 15.5 Tour selectors - crop tool (crop-settings, run-button)
// ---------------------------------------------------------------------------
test.describe("15.5 Tour selectors crop tool", () => {
test.describe("15.5 Tour selectors - crop tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/crop", { waitUntil: "domcontentloaded" });
await uploadFiles(page, SAMPLE_PDF);
@@ -154,9 +162,9 @@ test.describe("15.5 Tour selectors — crop tool", () => {
});
// ---------------------------------------------------------------------------
// 15.6 Tour selectors config modal (non-admin)
// 15.6 Tour selectors - config modal (non-admin)
// ---------------------------------------------------------------------------
test.describe("15.6 Tour selectors config modal", () => {
test.describe("15.6 Tour selectors - config modal", () => {
test.beforeEach(async ({ page }) => {
await openSettings(page);
});
@@ -175,13 +183,13 @@ test.describe("15.6 Tour selectors — config modal", () => {
});
// ---------------------------------------------------------------------------
// 15.7 Tour selectors admin config modal nav items
// 15.7 Tour selectors - admin config modal nav items
// Requires isAdmin:true in app-config so the proprietary admin sections render.
// These nav items are EE-only; the test is skipped in core-only builds where
// the sections are not registered.
// ---------------------------------------------------------------------------
test.describe("15.7 Tour selectors admin modal nav items", () => {
// enableLogin:true makes Landing require a session seed a JWT so the
test.describe("15.7 Tour selectors - admin modal nav items", () => {
// enableLogin:true makes Landing require a session - seed a JWT so the
// dashboard renders instead of redirecting to /login.
test.use({
stubOptions: { enableLogin: true, isAdmin: true },
@@ -216,7 +224,7 @@ test.describe("15.7 Tour selectors — admin modal nav items", () => {
if (!isPresent) {
test.skip(
true,
`admin-${section}-nav not rendered section may be EE-only or not yet ported to this build`,
`admin-${section}-nav not rendered - section may be EE-only or not yet ported to this build`,
);
return;
}
@@ -31,11 +31,11 @@ test.describe("Unsaved changes navigation guard", () => {
await page.goto("/split");
}
// After arriving at /split the file picker should still list the
// After arriving at /split the My Files page should still list the
// previously uploaded sample (NavigationGuard either kept us on
// /merge or moved us with state intact). A "no files" empty state
// here would indicate the guard silently dropped the workbench.
await page.getByTestId("files-button").click();
await page.getByTestId("my-files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
+13
View File
@@ -4,6 +4,7 @@
*/
import { ToolId } from "@app/types/toolId";
import { FolderId } from "@app/types/folder";
declare const tag: unique symbol;
export type FileId = string & { readonly [tag]: "FileId" };
@@ -36,6 +37,17 @@ export interface BaseFileMetadata {
parentFileId?: FileId; // Immediate parent file ID
toolHistory?: ToolOperation[]; // Tool chain for history tracking
/**
* The cloud folder this file lives in. Semantics:
* - `remoteStorageId == null` file is local-only; folderId MUST be null.
* - `remoteStorageId != null && folderId == null` file is at the cloud root.
* - `remoteStorageId != null && folderId == X` file lives in cloud folder X.
*
* The "Local" pseudo-folder in the UI is the predicate `remoteStorageId == null`;
* it has no corresponding {@code folderId} value. Folders are a server-only concept.
*/
folderId?: FolderId | null;
// Remote storage tracking
remoteStorageId?: number; // Server-side storage ID for this file chain
remoteStorageUpdatedAt?: number; // Timestamp when chain was last uploaded
@@ -44,6 +56,7 @@ export interface BaseFileMetadata {
remoteAccessRole?: string; // Access role for shared server files
remoteSharedViaLink?: boolean; // True when imported from a share link
remoteHasShareLinks?: boolean; // True when owner has shared this file
remoteHasUserShares?: boolean; // True when owner has invited specific users
remoteShareToken?: string; // Share token when file is from a share link
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Folder types for the advanced file manager.
* Folders provide a hierarchical organization layer on top of the flat
* file storage in IndexedDB. A folder is identified by a branded UUID
* and may reference a parent folder; a `null` parent means the root.
*/
declare const folderTag: unique symbol;
export type FolderId = string & { readonly [folderTag]: "FolderId" };
/** The root folder is represented in UI/state by `null`. */
export const ROOT_FOLDER_ID: null = null;
/**
* UI sentinel for the pinned "Local" pseudo-folder. Never sent to the server,
* never persisted to IndexedDB - only used as a `currentFolderId` value
* to scope the file grid to local-only files. The underlying data predicate
* is `remoteStorageId == null`.
*/
export const LOCAL_PSEUDO_FOLDER_ID = "__local__" as const;
export type LocalPseudoFolderId = typeof LOCAL_PSEUDO_FOLDER_ID;
/** Default colour palette used when no explicit colour is provided. */
export const FOLDER_COLOR_PALETTE = [
"#3b82f6",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#ec4899",
"#14b8a6",
"#f97316",
] as const;
/** Members of {@link FOLDER_COLOR_PALETTE}. Use this rather than `string` to keep callers honest. */
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
/** Persisted folder shape stored in IndexedDB. */
export interface FolderRecord {
id: FolderId;
name: string;
parentFolderId: FolderId | null;
/** Hex colour - either a palette member or any custom hex from a future picker. */
color?: string;
icon?: string;
createdAt: number;
updatedAt: number;
}
/**
* Folder tree node - derived from FolderRecord[] for rendering the tree
* navigator. Children are ordered by name (case-insensitive).
*/
export interface FolderTreeNode {
folder: FolderRecord;
children: FolderTreeNode[];
depth: number;
}
/** A path entry shown in the breadcrumb bar. `null` represents the root. */
export interface FolderBreadcrumbEntry {
id: FolderId | null;
name: string;
}
/**
* Generic RFC-4122 UUID regex (case-insensitive). Accepts any valid UUID variant - server-side
* {@code UUID.randomUUID()} is v4 in practice, but other tools and tests may produce v1/v3/v5.
* Being strict-v4-only here turned the {@code pullFromServer} merge into a silent skip whenever
* a non-v4 id arrived.
*/
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Runtime check that a value is a UUID. Throws on garbage from the wire. */
export function parseFolderId(value: unknown): FolderId {
if (typeof value !== "string" || !UUID_REGEX.test(value)) {
throw new Error(`Invalid FolderId: ${String(value)}`);
}
return value as FolderId;
}
export function createFolderId(): FolderId {
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
return window.crypto.randomUUID() as FolderId;
}
// Math.random fallback is non-cryptographic but acceptable here - these ids are
// not used as security tokens, only as opaque local handles. The brand is the
// contract; the entropy is best-effort.
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
}) as FolderId;
}
export function pickFolderColor(seed: string): FolderPaletteColor {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
const idx = Math.abs(hash) % FOLDER_COLOR_PALETTE.length;
return FOLDER_COLOR_PALETTE[idx]!;
}
@@ -3,6 +3,7 @@ export const BASE_WORKBENCH_TYPES = [
"viewer",
"pageEditor",
"fileEditor",
"myFiles",
] as const;
export type BaseWorkbenchType = (typeof BASE_WORKBENCH_TYPES)[number];
@@ -19,6 +19,12 @@ export function getStartupNavigationAction(
return null;
}
// The user is browsing their file library - don't auto-switch them out of
// the file manager just because a new upload landed.
if (currentWorkbench === "myFiles") {
return null;
}
// Already actively viewing in the viewer → update to the latest file
if (
previousFileCount > 0 &&
+11
View File
@@ -110,6 +110,7 @@
"dotenv": "^16.4.7",
"dpdm": "^3.14.0",
"eslint": "^10.0.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
@@ -8443,6 +8444,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+4
View File
@@ -124,6 +124,7 @@
"dotenv": "^16.4.7",
"dpdm": "^3.14.0",
"eslint": "^10.0.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
@@ -149,5 +150,8 @@
"@testing-library/user-event",
"@vitest/coverage-v8"
]
},
"overrides": {
"devalue": "^5.8.1"
}
}