remove selected and de-selected logic and change the compare tool to accomadate (#6095)

# Changes
         
1. Files are no longer selected/deselected, they're just on the
workbench or not.
2. Compare tool logic was changed to accommodate the changes in file
selection
3. Added a re-usable file selector component that allows you to select
files from the workbench / saved files / upload files and used it in the
compare tool.
<img width="2056" height="1081" alt="Screenshot 2026-04-10 at 5 28
07 PM"
src="https://github.com/user-attachments/assets/e04f1c0e-91fc-4564-a6f1-b9b992ca4f70"
/>

3. Files on active files are cleaner looking, on hover shows buttons,
tool runs, versions
        
<img width="325" height="378" alt="Screenshot 2026-04-10 at 4 56 52 PM"
src="https://github.com/user-attachments/assets/d29d88fe-2c72-4c5f-9650-1376085bcff6"
/>
<img width="286" height="363" alt="Screenshot 2026-04-10 at 4 57 05 PM"
src="https://github.com/user-attachments/assets/387c1709-de22-4cc8-b658-9f4c9f192af1"
/>


# How to test
                                                            
## Compare tool
  1. Open the Compare tool with no files on the workbench
3. Click the Original PDF picker → confirm the popover opens with Saved
Files and Workbench tabs
5. Switch to Saved Files, pick a file → confirm it appears as a card but
does not appear in the workbench file list
6. Pick a second file for Edited PDF → confirm the Compare button
becomes active
7. Hit Compare → confirm results appear in the workbench view
8. Hit Swap → confirm a re-run is triggered and results update correctly
9. Use the upload button → confirm a thumbnail appears on the card
immediately
10. Clear selected → confirm both slots reset and the workbench view
closes
## FileSelectorPicker                                        
1. Open either slot picker and switch between Saved Files / Workbench
tabs — confirm the active tab is remembered when you open the other
slot's picker
  3. Sort by A–Z then reopen — confirm sort preference persists
4. Upload a PDF via the Upload tab button — confirm thumbnail shows in
the card, file appears in Saved Files on next open
5. With a file in the other slot, confirm it is hidden from both tab
lists (excludeIds)
## File editor
1. Load 3+ files, run any tool (e.g. Compress) — confirm it runs on all
files without needing to select any
  2. Confirm Close All removes all files from the workbench
This commit is contained in:
EthanHealy01
2026-04-14 17:03:59 +01:00
committed by GitHub
parent 4ada46ca56
commit be85773852
34 changed files with 1534 additions and 845 deletions
+2
View File
@@ -1,5 +1,7 @@
dist/
node_modules/
src-tauri/target/
src-tauri/gen/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
+61 -35
View File
@@ -50,8 +50,8 @@ exportAndContinue = "Export & Continue"
false = "False"
fileSavedToDisk = "File saved to disk"
fileNotSavedToDisk = "Not saved to disk"
fileSelected = "Selected: {{filename}}"
filesSelected = "{{count}} files selected"
fileSelected = "{{filename}}"
filesSelected = "{{count}} files"
font = "Font"
fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text."
fontTypeTooltip = "Font family for the page numbers. Choose based on your document style."
@@ -77,7 +77,7 @@ multiPdfPrompt = "Select PDFs (2+)"
never = "Never"
no = "No"
noFavourites = "No favourites added"
noFileSelected = "No file selected. Please upload one."
noFileSelected = "No file loaded. Please upload one."
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
noOperationToUndo = "No operation to undo"
notAuthenticatedMessage = "User not authenticated."
@@ -2681,6 +2681,7 @@ used = "used"
[compare]
addFilesHint = "Add PDFs in the Files step to enable selection."
clearSelected = "Clear selected"
clearSlot = "Remove file"
cta = "Compare"
header = "Compare PDFs"
loading = "Comparing..."
@@ -2733,6 +2734,7 @@ title = "These PDFs look highly different"
[compare.edited]
label = "Edited PDF"
placeholder = "Select the edited PDF"
selectBaseFirst = "Select original PDF first"
[compare.error]
filesMissing = "Unable to locate the selected files. Please re-select them."
@@ -2788,6 +2790,7 @@ pageLabel = "Page"
confirm = "Swap and Re-run"
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."
@@ -3030,7 +3033,7 @@ maintainAspectRatio = "Maintain Aspect Ratio"
markdown = "Markdown"
maxAttachmentSize = "Maximum attachment size (MB)"
multiple = "Multiple"
noFileSelected = "No file selected. Use the file panel to add files."
noFileSelected = "No file loaded. Use the file panel to add files."
odpExt = "OpenDocument Presentation (.odp)"
odtExt = "OpenDocument Text (.odt)"
officeDocs = "Office Documents (Word, Excel, PowerPoint)"
@@ -3048,9 +3051,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b
pptExt = "PowerPoint (.pptx)"
results = "Results"
rtfExt = "Rich Text Format (.rtf)"
selectedFiles = "Selected files"
selectFilesPlaceholder = "Select files in the main view to get started"
selectSourceFormatFirst = "Select a source format first"
selectedFiles = "Active files"
selectFilesPlaceholder = "Add files to the workbench to get started"
selectSourceFormatFirst = "Choose a source format first"
settings = "Settings"
single = "Single"
sourceFormatPlaceholder = "Source format"
@@ -3142,7 +3145,7 @@ scarf = "Scarf Pixel"
[crop]
autoCrop = "Auto-crop whitespace"
header = "Crop PDF"
noFileSelected = "Select a PDF file to begin cropping"
noFileSelected = "Add a PDF file to begin cropping"
reset = "Reset to full PDF"
submit = "Apply Crop"
title = "Crop"
@@ -3454,21 +3457,24 @@ or = "or"
[fileEditor]
addFiles = "Add Files"
pageCount = "{{count}} pages"
pageCount_one = "{{count}} page"
pageCount_other = "{{count}} pages"
[fileManager]
active = "Active"
addToUpload = "Add to Upload"
clearAll = "Clear All"
clearSelection = "Clear Selection"
clearSelection = "Clear"
clickToUpload = "Click to upload files"
closeFile = "Close File"
delete = "Delete"
deleteAll = "Delete All"
deleteSelected = "Delete Selected"
deselectAll = "Deselect All"
deleteSelected = "Delete Files"
deselectAll = "Uncheck All"
details = "File Details"
download = "Download"
downloadSelected = "Download Selected"
downloadSelected = "Download Files"
dragDrop = "Drag & Drop files here"
dropFilesHere = "Drop files here"
failedToLoad = "Failed to load file to active set."
@@ -3477,7 +3483,7 @@ fileFormat = "Format"
fileHistory = "File History"
fileName = "Name"
fileSize = "Size"
filesSelected = "files selected"
filesSelected = "files"
filesStored = "files stored"
fileVersion = "Version"
googleDrive = "Google Drive"
@@ -3493,7 +3499,7 @@ mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
myFiles = "My Files"
noFiles = "No files available"
noFileSelected = "No files selected"
noFileSelected = "No files chosen"
noFilesFound = "No files found matching your search"
noRecentFiles = "No recent files found"
openFile = "Open File"
@@ -3503,11 +3509,11 @@ openInPageEditor = "Open in Page Editor"
recent = "Recent"
reloadFiles = "Reload Files"
restore = "Restore"
saveSelected = "Save Selected"
saveSelected = "Save Files"
searchFiles = "Search files..."
selectAll = "Select All"
selectedCount = "{{count}} selected"
selectedFiles = "Selected Files"
selectAll = "Check All"
selectedCount = "{{count}} checked"
selectedFiles = "Files Added"
showAll = "Show All"
showHistory = "Show History"
sortByDate = "Sort by Date"
@@ -3521,7 +3527,7 @@ subtitle = "Add files to your storage for easy access across tools"
supportMessage = "Powered by browser database storage for unlimited capacity"
title = "Upload PDF Files"
toolChain = "Tools Applied"
totalSelected = "Total Selected"
totalSelected = "Total Files"
unsupported = "Unsupported"
unzip = "Unzip"
uploadError = "Failed to upload some files."
@@ -3553,7 +3559,8 @@ makeCopy = "Make a copy"
owner = "Owner"
ownerUnknown = "Unknown"
share = "Share"
shareSelected = "Share Selected"
shareSelected = "Share Files"
shared = "Shared"
sharedByYou = "Shared by you"
sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy."
sharedEditNoticeConfirm = "Got it"
@@ -3563,19 +3570,38 @@ sharing = "Sharing"
storageState = "Storage"
synced = "Synced"
updateOnServer = "Update on Server"
uploadSelected = "Upload Selected"
uploadSelected = "Upload Files"
uploadToServer = "Upload to Server"
[files]
addFiles = "Add files"
created = "Created"
selectFromWorkbench = "Select files from the workbench or "
selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or "
selectFromWorkbench = "Add files to the workbench or "
selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or "
size = "File Size"
title = "Files"
upload = "Upload"
uploadFiles = "Upload Files"
[fileSelectorPicker]
empty = "No files available"
placeholder = "Select file"
tabListLabel = "Saved files, workbench, upload"
upload = "Upload from computer"
[fileSelectorPicker.sort]
dateAsc = "Oldest first"
dateDesc = "Newest first"
dateLabel = "Latest"
nameAsc = "A to Z"
nameDesc = "Z to A"
nameLabel = "A–Z"
[fileSelectorPicker.tabs]
saved = "Saved files"
workbench = "Workbench"
upload = "Upload"
[fileToPDF]
credit = "This service uses LibreOffice and Unoconv for file conversion."
header = "Convert any file to PDF"
@@ -4429,7 +4455,7 @@ title = "Markdown To PDF"
submit = "Merge"
tags = "merge,Page operations,Back end,server side"
title = "Merge"
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them."
goToFileEditor = "Go to file editor"
[merge.error]
@@ -4453,7 +4479,7 @@ title = "Remove Digital Signature"
ascending = "Ascending"
dateModified = "Date Modified"
descending = "Descending"
description = "Files will be merged in the order they're selected. Drag to reorder or sort below."
description = "Files will be merged in the order shown. Drag to reorder or sort below."
filename = "File Name"
label = "Sort By"
sort = "Sort"
@@ -4834,8 +4860,8 @@ text = "Foreground places the overlay on top of the page. Background places it b
title = "Overlay Position"
[pageEdit]
deselectAll = "Select None"
selectAll = "Select All"
deselectAll = "None"
selectAll = "Check All"
[pageEditor]
actualSize = "Actual Size"
@@ -4889,6 +4915,7 @@ lineNumbers = "Line numbers"
renderMarkdown = "Render markdown"
[rightRail]
closeAll = "Close All Files"
closeSelected = "Close Selected Files"
selectAll = "Select All"
deselectAll = "Deselect All"
@@ -7405,10 +7432,10 @@ viewActivity = "View activity"
viewed = "Viewed"
viewsCount = "Views: {{count}}"
downloaded = "Downloaded"
bulkDescription = "Create one link to share all selected files with signed-in users."
bulkTitle = "Share selected files"
bulkDescription = "Create one link to share all checked files with signed-in users."
bulkTitle = "Share checked files"
copyLink = "Copy share link"
fileCount = "{{count}} files selected"
fileCount = "{{count}} files"
ownerOnly = "Only the owner can manage sharing."
selectSingleFile = "Select a single file to manage sharing."
@@ -7422,9 +7449,9 @@ success = "Uploaded to server"
title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
bulkDescription = "This uploads the selected files to your server storage."
bulkTitle = "Upload selected files"
fileCount = "{{count}} files selected"
bulkDescription = "This uploads the checked files to your server storage."
bulkTitle = "Upload checked files"
fileCount = "{{count}} files"
more = " +{{count}} more"
[storage]
@@ -7546,11 +7573,10 @@ endpointUnavailable = "This tool is unavailable on your server."
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
invalidParams = "Fill in the required settings."
noFiles = "Add a file to get started."
viewerMode = "Switch to the file editor to select multiple files."
viewerMode = "Switch to the file editor to add multiple files."
singleFileScope = "Only applying to: {{fileName}}"
scopeThisFile = "this file"
scopeFiles = "files"
selectFilesHint = "Select files in Active Files to run this tool"
[tools]
noSearchResults = "No tools found"
+2 -1
View File
@@ -20,7 +20,7 @@ interface FileManagerProps {
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect, maxSelectable } = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false);
@@ -196,6 +196,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
maxSelectable={maxSelectable}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
import { useFileSelection, useFileState, useFileManagement, useFileActions } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService";
@@ -36,14 +36,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions: fileActions } = useFileActions();
const { actions: fileContextActions } = useFileContext();
const { clearAllFileErrors } = fileContextActions;
const { selectedFileIds, setSelectedFiles } = useFileSelection();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]);
const selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -51,9 +47,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
// Get viewer context for setting active file index and ID
const { setActiveFileIndex, setActiveFileId } = useViewer();
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
@@ -64,7 +57,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
const showError = useCallback((message: string) => {
alert({ alertType: "error", title: "Error", body: message, expandable: true });
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
@@ -75,60 +67,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
}, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
// Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids;
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
setSelectedFiles(idsToSelect);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on select all:", error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on deselect:", error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
const handleCloseSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
void removeFiles(selectedFileIds, false);
setSelectedFiles([]);
}, [selectedFileIds, removeFiles, setSelectedFiles]);
const handleCloseAllFiles = useCallback(() => {
void removeFiles(
activeStirlingFileStubs.map((r) => r.id),
false,
);
}, [activeStirlingFileStubs, removeFiles]);
useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll: handleSelectAllFiles,
onDeselectAll: handleDeselectAllFiles,
onCloseSelected: handleCloseSelectedFiles,
totalItems: activeStirlingFileStubs.length,
onCloseAll: handleCloseAllFiles,
});
// Process uploaded files using context
@@ -163,46 +113,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
const toggleFile = useCallback(
(fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
@@ -441,12 +351,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
@@ -0,0 +1,246 @@
.card {
width: 220px;
max-width: 220px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 10px 10px 12px;
position: relative;
--file-text-height: 58px;
--file-text-bottom: 12px;
--thumb-text-gap: 6px;
padding-bottom: calc(var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap));
user-select: none;
cursor: pointer;
}
/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */
.thumbInner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
max-width: 100%;
}
/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */
.thumbUnit {
display: flex;
flex-direction: column;
align-items: center;
max-height: 100%;
}
/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */
.toolChainBar {
flex-shrink: 0;
width: 100%;
height: 22px;
text-align: center;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
line-height: 22px;
margin-bottom: 4px;
}
.card:hover .toolChainBar,
.card:focus-within .toolChainBar {
opacity: 1;
}
.thumbWrap {
width: 100%;
position: relative;
height: 260px;
display: flex;
align-items: center;
justify-content: center;
}
.thumbContainer {
aspect-ratio: var(--thumb-aspect, 8.5 / 11);
width: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
transition:
box-shadow 0.15s ease,
transform 0.2s ease;
}
.thumbContainer[data-supported="false"] {
filter: grayscale(80%);
opacity: 0.65;
}
.thumbContainer:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
background: var(--bg-surface);
display: block;
position: relative;
z-index: 1;
}
/* Text block pinned below thumbnail */
.fileText {
position: absolute;
left: 10px;
right: 10px;
bottom: var(--file-text-bottom);
height: var(--file-text-height);
text-align: center;
pointer-events: none;
--file-name-line-height: 18px;
--file-name-lines: 2;
--file-meta-line-height: 14px;
--file-meta-gap: 4px;
}
.fileName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: var(--file-name-line-height);
height: calc(var(--file-name-lines) * var(--file-name-line-height));
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
}
.fileMeta {
font-size: 11px;
color: var(--text-secondary);
line-height: var(--file-meta-line-height);
height: var(--file-meta-line-height);
overflow: hidden;
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
/* CSS-hover driven menu visibility */
.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 0;
pointer-events: none;
}
.card:hover [data-hover-action-menu-mode="cssHover"][data-force-visible="false"],
.card:focus-within [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 1;
pointer-events: auto;
}
/* Error overlay */
.errorOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(220, 38, 38, 0.12);
border-radius: 12px;
z-index: 2;
pointer-events: none;
}
.errorPill {
background: var(--mantine-color-red-6);
color: white;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
}
/* Badges row pinned to top-left of thumbnail - always visible */
.thumbBadges {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: none;
}
.ownershipBadge {
background: var(--mantine-color-blue-6);
color: white;
border-radius: 6px;
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.versionBadgeThumb {
background: var(--mantine-color-blue-6);
border-radius: 4px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: white;
line-height: 16px;
white-space: nowrap;
}
.pinnedBadge {
background: var(--mantine-color-yellow-6);
color: white;
border-radius: 4px;
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
cursor: grab;
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.card:hover .dragHandle,
.card:focus-within .dragHandle {
opacity: 0.5;
pointer-events: auto;
}
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
import { Text, Modal, Button, Group, Stack, Loader, ActionIcon, Tooltip } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
@@ -11,18 +11,16 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
@@ -36,12 +34,8 @@ interface FileEditorThumbnailProps {
file: StirlingFileStub;
index: number;
totalFiles: number;
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
@@ -51,12 +45,8 @@ interface FileEditorThumbnailProps {
const FileEditorThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
@@ -69,40 +59,24 @@ const FileEditorThumbnail = ({
const DownloadOutlinedIcon = icons.download;
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useIsMobile();
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const actualFile = useMemo(() => activeFiles.find((f) => f.fileId === file.id), [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const hasError = state.ui.errorFileIds.includes(file.id);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
// Flip aspect ratio for 90°/270° rotated PDFs so the card shape matches the page orientation
const firstPageRotation = file.processedFile?.pages?.[0]?.rotation ?? 0;
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
const thumbAspect = isLandscape ? "11 / 8.5" : "8.5 / 11";
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
return formatFileSize(file.size);
}, [file.size]);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
@@ -116,6 +90,7 @@ const FileEditorThumbnail = ({
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
@@ -125,6 +100,7 @@ const FileEditorThumbnail = ({
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
@@ -140,7 +116,13 @@ const FileEditorThumbnail = ({
}).format(d);
}, [file.lastModified]);
// ---- Drag & drop wiring ----
const [isDragging, setIsDragging] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
@@ -153,32 +135,19 @@ const FileEditorThumbnail = ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
selectedFiles: [file.id],
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
getData: () => ({ type: "file", fileId: file.id }),
canDrop: ({ source }) => source.data.type === "file" && source.data.fileId !== file.id,
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
if (source.data.type === "file" && onReorderFiles) {
onReorderFiles(source.data.fileId as FileId, file.id, source.data.selectedFiles as FileId[]);
}
},
});
@@ -188,13 +157,11 @@ const FileEditorThumbnail = ({
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
[file.id, file.name, onReorderFiles],
);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
setShowCloseModal(true);
}, []);
const handleCloseWithConfirmation = useCallback(() => setShowCloseModal(true), []);
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
@@ -227,17 +194,11 @@ const FileEditorThumbnail = ({
return;
}
}
// Then close
onCloseFile(file.id);
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(
() => [
{
@@ -249,6 +210,25 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
},
},
{
id: "pin",
icon: <PushPinIcon style={{ fontSize: 20 }} />,
label: isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)"),
onClick: (e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
},
},
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
@@ -258,36 +238,32 @@ const FileEditorThumbnail = ({
onDownloadFile(file.id);
},
},
...(canUpload || canShare
...(canUpload
? [
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
@@ -321,7 +297,10 @@ const FileEditorThumbnail = ({
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
@@ -329,13 +308,13 @@ const FileEditorThumbnail = ({
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try {
fileActions.clearFileError(file.id);
@@ -347,7 +326,6 @@ const FileEditorThumbnail = ({
sharedEditNoticeShownRef.current = true;
setShowSharedEditNotice(true);
}
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
@@ -355,12 +333,9 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
const metaLine = [`v${file.versionNumber}`, dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
.filter(Boolean)
.join(" - ");
return (
<div
@@ -368,193 +343,117 @@ const FileEditorThumbnail = ({
data-file-id={file.id}
data-testid="file-thumbnail"
data-tour="file-card-checkbox"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
className={`${styles.card} select-none`}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onMouseEnter={() => setShowHoverMenu(true)}
onMouseLeave={() => setShowHoverMenu(false)}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t("error._value", "Error")}</span>
<div className={styles.thumbInner}>
{/* Thumbnail area */}
<div className={styles.thumbWrap}>
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
<div className={styles.thumbUnit}>
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
<div className={styles.toolChainBar}>
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth="100%"
color="var(--mantine-color-gray-7)"
/>
)}
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
{/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<ActionIcon
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip
label={
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
aria-label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
<div
className={styles.thumbContainer}
data-supported={isSupported}
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
</div>
</div>
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>{t("error._value", "Error")}</span>
</div>
)}
{/* Title + meta line */}
<div
style={{
padding: "0.5rem",
textAlign: "center",
background: "var(--file-card-bg)",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
>
<Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text>
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ""}
{pageLabel ? ` - ${pageLabel}` : ""}
</Text>
</div>
{/* Thumbnail image or loading state */}
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
className={styles.thumbImage}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
</div>
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
{/* Tool chain display at bottom */}
{file.toolHistory && (
<div
style={{
position: "absolute",
bottom: "4px",
left: "4px",
right: "4px",
padding: "4px 6px",
textAlign: "center",
fontWeight: 600,
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth={"100%"}
color="var(--mantine-color-gray-7)"
/>
{/* Badges — visible on hover via CSS */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>v{file.versionNumber}</span>
{isPinned && (
<span className={styles.pinnedBadge}>
<PushPinIcon style={{ fontSize: 12 }} />
</span>
)}
{isSharedFile && !isOwnedOrLocal && (
<span className={styles.ownershipBadge}>{t("fileManager.sharedWithYou", "Shared")}</span>
)}
{isEncrypted && (
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<ActionIcon
size="xs"
variant="filled"
color="yellow"
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
style={{ pointerEvents: "auto" }}
>
<LockOpenIcon style={{ fontSize: 12 }} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
</div>
)}
{/* end thumbUnit */}
{/* Drag handle */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu show={isMobile} actions={hoverActions} position="outside" visibility="cssHover" />
</div>
{/* Hover Menu */}
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
{/* Close Confirmation Modal */}
<Modal
@@ -569,7 +468,7 @@ const FileEditorThumbnail = ({
<>
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -587,7 +486,7 @@ const FileEditorThumbnail = ({
<>
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -601,6 +500,8 @@ const FileEditorThumbnail = ({
)}
</Stack>
</Modal>
{/* Shared edit notice modal */}
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
@@ -5,58 +5,27 @@ import LocalIcon from "@app/components/shared/LocalIcon";
interface FileEditorRightRailButtonsParams {
totalItems: number;
selectedCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
onCloseSelected: () => void;
onCloseAll: () => void;
}
export function useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll,
onDeselectAll,
onCloseSelected,
}: FileEditorRightRailButtonsParams) {
export function useFileEditorRightRailButtons({ totalItems, onCloseAll }: FileEditorRightRailButtonsParams) {
const { t, i18n } = useTranslation();
const buttons = useMemo<RightRailButtonWithAction[]>(
() => [
{
id: "file-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.selectAll", "Select All"),
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
section: "top" as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: "file-deselect-all",
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.deselectAll", "Deselect All"),
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
section: "top" as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: "file-close-selected",
id: "file-close-all",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
tooltip: t("rightRail.closeAll", "Close All Files"),
ariaLabel: typeof t === "function" ? t("rightRail.closeAll", "Close All Files") : "Close All Files",
section: "top" as const,
order: 30,
disabled: selectedCount === 0,
disabled: totalItems === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
onClick: onCloseAll,
},
],
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
[t, i18n.language, totalItems, onCloseAll],
);
useRightRailButtons(buttons);
@@ -86,7 +86,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
<PrivateContent>{currentFile ? currentFile.name : "No file loaded"}</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ""}
@@ -148,14 +148,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box
p="sm"
style={{
cursor: isHistoryFile || isActive ? "default" : "pointer",
backgroundColor: isActive
? "var(--file-active-bg)"
: isSelected
? "var(--mantine-color-gray-1)"
: shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
cursor: isHistoryFile ? "default" : "pointer",
backgroundColor: isSelected || shouldShowHovered ? "var(--mantine-color-gray-1)" : "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
transition: "background-color 0.15s ease",
userSelect: "none",
@@ -165,7 +159,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
}}
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -175,16 +169,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box>
{/* Checkbox for regular files only */}
<Checkbox
checked={isActive || isSelected}
checked={isSelected}
onChange={() => {}} // Handled by parent onClick
size="sm"
pl="sm"
pr="xs"
disabled={isActive}
color={isActive ? "green" : undefined}
styles={{
input: {
cursor: isActive ? "not-allowed" : "pointer",
cursor: "pointer",
},
}}
/>
@@ -162,7 +162,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
{t("storageShare.bulkDescription", "Create one link to share all selected files with signed-in users.")}
</Text>
<Text size="sm" c="dimmed">
{t("storageShare.fileCount", "{{count}} files selected", {
{t("storageShare.fileCount", "{{count}} files", {
count: files.length,
})}
</Text>
@@ -89,7 +89,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
<Stack gap="sm">
<Text size="sm">{t("storageUpload.bulkDescription", "This uploads the selected files to your server storage.")}</Text>
<Text size="sm" c="dimmed">
{t("storageUpload.fileCount", "{{count}} files selected", {
{t("storageUpload.fileCount", "{{count}} files", {
count: files.length,
})}
</Text>
@@ -214,7 +214,7 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
{selectedFileIds.length} {t("fileManager.filesSelected", "files")}
</Text>
)}
</>
@@ -0,0 +1,244 @@
/* ── Trigger ─────────────────────────────────────── */
.trigger {
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
background: var(--bg-surface);
cursor: pointer;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
user-select: none;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.trigger:hover:not(.triggerDisabled) {
border-color: var(--mantine-color-blue-5);
}
.triggerDisabled {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
.triggerOpen {
border-color: var(--mantine-color-blue-5);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
}
/* ── Popover dropdown ────────────────────────────── */
.dropdown {
padding: 0;
overflow: hidden;
border-radius: var(--radius-md) !important;
min-width: 340px;
}
/* ── Header area (tabs + sort) ───────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.3rem 0.45rem;
gap: 0.4rem;
border-bottom: 1px solid var(--border-default);
background: var(--bg-surface);
}
.tabGroup {
display: flex;
align-items: stretch;
flex: 1;
min-width: 0;
}
/* One slim row: Saved files | Workbench | Upload (text only, no icon) */
.slimTabBar {
display: flex;
align-items: stretch;
flex: 1;
min-width: 0;
min-height: 24px;
max-height: 26px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--mantine-color-default-hover) 85%, transparent);
padding: 1px;
gap: 1px;
box-sizing: border-box;
}
.slimTab,
.slimTabActive {
flex: 1 1 0;
min-width: 0;
margin: 0;
border: none;
cursor: pointer;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.15;
letter-spacing: 0.01em;
padding: 1px 0.35rem;
border-radius: calc(var(--radius-sm) - 2px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background 0.1s ease,
color 0.1s ease;
}
.slimTab {
background: transparent;
color: var(--mantine-color-dimmed);
}
.slimTab:hover {
color: var(--mantine-color-text);
background: color-mix(in srgb, var(--mantine-color-body) 60%, transparent);
}
.slimTabActive {
background: var(--mantine-color-blue-filled);
color: #fff;
}
.slimTabUpload {
flex: 0 0 auto;
margin: 0;
border: none;
cursor: pointer;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.15;
padding: 1px 0.45rem;
border-radius: calc(var(--radius-sm) - 2px);
white-space: nowrap;
background: transparent;
color: var(--mantine-color-dimmed);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
}
.slimTabUpload:hover:not(:disabled) {
color: var(--mantine-color-blue-filled);
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
}
.slimTabUpload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Sort button group ───────────────────────────── */
.sortGroup {
display: flex;
gap: 2px;
background: var(--mantine-color-default-hover);
border-radius: var(--radius-sm);
padding: 2px;
}
.sortBtn {
padding: 2px 8px;
font-size: 0.7rem;
border-radius: calc(var(--radius-sm) - 2px);
cursor: pointer;
border: none;
background: transparent;
color: var(--mantine-color-dimmed);
line-height: 1.6;
transition:
background 0.1s,
color 0.1s;
white-space: nowrap;
}
.sortBtn:hover {
color: var(--mantine-color-text);
}
.sortBtnActive {
background: var(--mantine-color-blue-filled);
color: white;
}
.sortArrow {
opacity: 0.85;
font-size: 0.65rem;
vertical-align: middle;
}
/* ── Scrollable file list ────────────────────────── */
.list {
padding: 0;
}
/* ── Individual file row ─────────────────────────── */
.fileItem {
width: 100%;
padding: 0.625rem 0.875rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
cursor: pointer;
background: transparent;
border: none;
border-top: 1px solid var(--border-default);
text-align: left;
transition: background 0.08s ease;
}
.fileItem:first-child {
border-top: none;
}
.fileItem:hover:not(:disabled) {
background: var(--mantine-color-default-hover);
}
.fileItem:disabled {
cursor: wait;
}
.fileItemContent {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.fileName {
font-size: 0.8125rem;
font-weight: 500;
color: var(--mantine-color-text);
}
.fileMeta {
font-size: 0.6875rem;
color: var(--mantine-color-dimmed);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Empty / loading states ──────────────────────── */
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1rem;
flex-direction: column;
gap: 0.5rem;
color: var(--mantine-color-dimmed);
}
@@ -0,0 +1,408 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Box, Popover, ScrollArea, Text, Loader, FileButton } from "@mantine/core";
import AddIcon from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
import { createStirlingFile, createFileId, createNewStirlingFileStub } from "@app/types/fileContext";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useFileManager } from "@app/hooks/useFileManager";
import { fileStorage } from "@app/services/fileStorage";
import apiClient from "@app/services/apiClient";
import { parseContentDispositionFilename, extractLatestFilesFromBundle } from "@app/services/shareBundleUtils";
import { truncateCenter } from "@app/utils/textUtils";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import styles from "@app/components/shared/FileSelectorPicker.module.css";
const LS_TAB = "filePicker.tab";
const LS_SORT = "filePicker.sort";
const LS_SORT_DIR = "filePicker.sortDir";
function lsGet<const T extends readonly string[]>(key: string, fallback: T[number], valid: T): T[number] {
try {
const v = localStorage.getItem(key);
const allowed = valid as readonly string[];
if (v && allowed.includes(v)) return v as T[number];
} catch {
/* ignore */
}
return fallback;
}
function lsSet(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* ignore */
}
}
function formatBytes(bytes: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(ms: number | undefined): string {
if (!ms) return "";
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function buildMeta(stub: StirlingFileStub): string {
const parts: string[] = [];
const pages = stub.processedFile?.totalPages;
if (pages) parts.push(`${pages} ${pages === 1 ? "page" : "pages"}`);
const size = formatBytes(stub.size ?? 0);
if (size) parts.push(size);
const date = formatDate(stub.lastModified || stub.createdAt);
if (date) parts.push(date);
return parts.join(" · ");
}
export interface FileSelectorResult {
stub: StirlingFileStub;
stirlingFile: StirlingFile;
}
export interface FileSelectorPickerProps {
placeholder?: string;
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
excludeIds?: string[];
disabled?: boolean;
/**
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
* Files are NOT added to the workbench — data is loaded inline.
*/
onSelect: (result: FileSelectorResult) => void;
}
export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = false, onSelect }: FileSelectorPickerProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() => lsGet(LS_TAB, "saved", ["workbench", "saved"]));
const [sortBy, setSortBy] = useState<"date" | "name">(() => lsGet(LS_SORT, "date", ["date", "name"]));
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
const [savedStubs, setSavedStubs] = useState<StirlingFileStub[]>([]);
const [savedLoading, setSavedLoading] = useState(false);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const { fileStubs: workbenchStubs } = useAllFiles();
const { selectors } = useFileContext();
const { loadRecentFiles } = useFileManager();
const handleTabChange = useCallback((tab: "workbench" | "saved") => {
lsSet(LS_TAB, tab);
setActiveTab(tab);
}, []);
const handleSortChange = useCallback(
(sort: "date" | "name") => {
if (sort === sortBy) {
const newDir = sortDir === "asc" ? "desc" : "asc";
lsSet(LS_SORT_DIR, newDir);
setSortDir(newDir);
} else {
const defaultDir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
lsSet(LS_SORT, sort);
lsSet(LS_SORT_DIR, defaultDir);
setSortBy(sort);
setSortDir(defaultDir);
}
},
[sortBy, sortDir],
);
// Sync tab/sort from localStorage whenever this picker opens.
// Both slot pickers mount simultaneously so their useState initialisers run at
// the same time — a tab change in one picker writes to localStorage but the
// other picker's React state is stale. Re-reading on open fixes that.
useEffect(() => {
if (!isOpen) return;
setActiveTab(lsGet(LS_TAB, "saved", ["workbench", "saved"]));
setSortBy(lsGet(LS_SORT, "date", ["date", "name"]));
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
}, [isOpen]);
// Load saved files when the saved tab is active and the picker is open
useEffect(() => {
if (activeTab !== "saved" || !isOpen) return;
setSavedLoading(true);
loadRecentFiles()
.then(setSavedStubs)
.finally(() => setSavedLoading(false));
}, [activeTab, isOpen, loadRecentFiles]);
const workbenchIdSet = useMemo(() => new Set(workbenchStubs.map((s) => s.id)), [workbenchStubs]);
const displayStubs = useMemo(() => {
const base = activeTab === "workbench" ? workbenchStubs : savedStubs;
const filtered = base.filter((s) => !excludeIds.includes(s.id));
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) =>
sortBy === "name"
? dir * a.name.localeCompare(b.name)
: dir * ((a.lastModified || a.createdAt || 0) - (b.lastModified || b.createdAt || 0)),
);
}, [activeTab, workbenchStubs, savedStubs, excludeIds, sortBy, sortDir]);
const loadAndSelect = useCallback(
async (stub: StirlingFileStub) => {
if (loadingId) return;
// Workbench file — get StirlingFile directly from FileContext (no loading needed)
if (workbenchIdSet.has(stub.id)) {
const sf = selectors.getFile(stub.id as FileId);
if (sf) {
// Prefer the workbench stub (has thumbnail) over the saved stub (may not)
const workbenchStub = selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
onSelect({ stub: workbenchStub, stirlingFile: sf });
setIsOpen(false);
}
return;
}
// Saved file — load bytes without touching the workbench
setLoadingId(stub.id);
try {
let stirlingFile: StirlingFile | null = null;
if (stub.remoteShareToken) {
const res = await apiClient.get(`/api/v1/storage/share-links/${stub.remoteShareToken}`, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || "";
const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || "";
const files = await extractLatestFilesFromBundle(
res.data as Blob,
parseContentDispositionFilename(disp) || "shared-file",
ct,
);
if (files[0]) stirlingFile = createStirlingFile(files[0], createFileId());
} else if (stub.remoteStorageId) {
const res = await apiClient.get(`/api/v1/storage/files/${stub.remoteStorageId}/download`, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || "";
const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || "";
const files = await extractLatestFilesFromBundle(
res.data as Blob,
parseContentDispositionFilename(disp) || stub.name,
ct,
);
if (files[0]) stirlingFile = createStirlingFile(files[0], stub.id as FileId);
} else {
// Local IndexedDB file
const localFile = await fileStorage.getStirlingFile(stub.id);
if (localFile) stirlingFile = localFile;
}
if (stirlingFile) {
// Generate thumbnail on-the-fly if the stub doesn't already have one
let resolvedStub = stub;
if (!resolvedStub.thumbnailUrl) {
try {
const thumbnail = await generateThumbnailForFile(stirlingFile);
if (thumbnail) {
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
// Persist so subsequent opens don't regenerate
void fileStorage.updateThumbnail(stirlingFile.fileId as FileId, thumbnail);
}
} catch {
// Non-fatal — thumbnail simply won't show
}
}
onSelect({ stub: resolvedStub, stirlingFile });
setIsOpen(false);
}
} catch (err) {
console.error("FileSelectorPicker: failed to load file", err);
} finally {
setLoadingId(null);
}
},
[loadingId, workbenchIdSet, selectors, onSelect],
);
const handleUpload = useCallback(
async (file: File | null) => {
if (!file || uploadBusy || disabled) return;
setUploadBusy(true);
try {
const id = createFileId();
let stub = createNewStirlingFileStub(file, id);
const stirlingFile = createStirlingFile(file, id);
// Generate a first-page thumbnail for the uploaded file
try {
const thumbnail = await generateThumbnailForFile(file);
if (thumbnail) stub = { ...stub, thumbnailUrl: thumbnail };
} catch {
// Non-fatal — thumbnail simply won't show
}
await fileStorage.storeStirlingFile(stirlingFile, stub);
lsSet(LS_TAB, "saved");
setActiveTab("saved");
const refreshed = await loadRecentFiles();
setSavedStubs(refreshed);
onSelect({ stub, stirlingFile });
setIsOpen(false);
} catch (err) {
console.error("FileSelectorPicker: upload failed", err);
} finally {
setUploadBusy(false);
}
},
[disabled, loadRecentFiles, onSelect, uploadBusy],
);
const triggerClass = [styles.trigger, disabled ? styles.triggerDisabled : "", isOpen ? styles.triggerOpen : ""]
.filter(Boolean)
.join(" ");
return (
<Popover
opened={isOpen}
onChange={setIsOpen}
onClose={() => setIsOpen(false)}
position="bottom-start"
withinPortal
shadow="md"
closeOnClickOutside
clickOutsideEvents={["mousedown", "touchstart"]}
>
<Popover.Target>
<Box
className={triggerClass}
style={{ width: "100%" }}
onClick={() => {
if (!disabled) setIsOpen((o) => !o);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === "Enter" || e.key === " ")) setIsOpen((o) => !o);
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<Text size="sm" c="dimmed" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{placeholder || t("fileSelectorPicker.placeholder", "Select file")}
</Text>
<AddIcon style={{ fontSize: 18, color: "var(--mantine-color-dimmed)", flexShrink: 0 }} />
</Box>
</Popover.Target>
<Popover.Dropdown className={styles.dropdown}>
<div className={styles.header}>
<div className={styles.tabGroup}>
<div
className={styles.slimTabBar}
role="group"
aria-label={t("fileSelectorPicker.tabListLabel", "Saved files, workbench, upload")}
>
<button
type="button"
aria-pressed={activeTab === "saved"}
className={activeTab === "saved" ? styles.slimTabActive : styles.slimTab}
onClick={() => handleTabChange("saved")}
>
{t("fileSelectorPicker.tabs.saved", "Saved files")}
</button>
<button
type="button"
aria-pressed={activeTab === "workbench"}
className={activeTab === "workbench" ? styles.slimTabActive : styles.slimTab}
onClick={() => handleTabChange("workbench")}
>
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
</button>
<FileButton onChange={handleUpload} accept=".pdf,application/pdf" disabled={disabled || uploadBusy}>
{(fbProps) => (
<button
{...fbProps}
type="button"
className={styles.slimTabUpload}
aria-label={t("fileSelectorPicker.upload", "Upload from computer")}
title={t("fileSelectorPicker.upload", "Upload from computer")}
disabled={disabled || uploadBusy}
>
{uploadBusy ? <Loader size={11} /> : t("fileSelectorPicker.tabs.upload", "Upload")}
</button>
)}
</FileButton>
</div>
</div>
<div className={styles.sortGroup}>
<button
type="button"
className={[styles.sortBtn, sortBy === "date" ? styles.sortBtnActive : ""].join(" ")}
onClick={() => handleSortChange("date")}
title={
sortDir === "desc"
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
}
>
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
{sortBy === "date" && <span className={styles.sortArrow}>{sortDir === "desc" ? " ↓" : " ↑"}</span>}
</button>
<button
type="button"
className={[styles.sortBtn, sortBy === "name" ? styles.sortBtnActive : ""].join(" ")}
onClick={() => handleSortChange("name")}
title={
sortDir === "asc"
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
}
>
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
{sortBy === "name" && <span className={styles.sortArrow}>{sortDir === "asc" ? " ↑" : " ↓"}</span>}
</button>
</div>
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
) : displayStubs.length === 0 ? (
<div className={styles.emptyState}>
<Text size="sm">{t("fileSelectorPicker.empty", "No files available")}</Text>
</div>
) : (
displayStubs.map((stub) => {
const meta = buildMeta(stub);
const isItemLoading = loadingId === stub.id;
return (
<button
key={stub.id}
type="button"
className={styles.fileItem}
onClick={() => void loadAndSelect(stub)}
disabled={!!loadingId}
>
<div className={styles.fileItemContent}>
<span className={styles.fileName} title={stub.name}>
{truncateCenter(stub.name, 48)}
</span>
{meta && <span className={styles.fileMeta}>{meta}</span>}
</div>
{isItemLoading && <Loader size="xs" />}
</button>
);
})
)}
</ScrollArea>
</Popover.Dropdown>
</Popover>
);
}
@@ -18,19 +18,39 @@ interface HoverActionMenuProps {
actions: HoverAction[];
position?: "inside" | "outside";
className?: string;
visibility?: "state" | "cssHover";
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ show, actions, position = "inside", className = "" }) => {
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
show,
actions,
position = "inside",
className = "",
visibility = "state",
}) => {
const visibleActions = actions.filter((action) => !action.hidden);
if (visibleActions.length === 0) {
return null;
}
const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU };
if (visibility === "state") {
style.opacity = show ? 1 : 0;
style.pointerEvents = show ? "auto" : "none";
} else if (show) {
// Force visible (e.g. mobile) even when CSS-hover mode is active.
style.opacity = 1;
style.pointerEvents = "auto";
}
return (
<div
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
style={style}
data-hover-action-menu="true"
data-hover-action-menu-mode={visibility}
data-force-visible={show ? "true" : "false"}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -26,11 +26,11 @@ const MultiSelectControls = ({
<Box mb="md" p="md" style={{ backgroundColor: "var(--mantine-color-blue-0)", borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedCount} {t("fileManager.filesSelected", "files selected")}
{selectedCount} {t("fileManager.filesSelected", "files")}
</Text>
<Group>
<Button size="xs" variant="light" onClick={onClearSelection}>
{t("fileManager.clearSelection", "Clear Selection")}
{t("fileManager.clearSelection", "Clear files")}
</Button>
{onAddToUpload && (
@@ -39,9 +39,9 @@ const CreateSessionPanel = ({
) : (
<>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "Selected file")}</div>
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "File")}</div>
<div className="quick-access-popout__row-title">
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file selected")}
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")}
</div>
</div>
@@ -10,6 +10,9 @@ import {
} from "@app/components/tools/compare/compare";
import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { truncateCenter } from "@app/utils/textUtils";
const COMPARE_PANE_TITLE_MAX_LEN = 48;
// utilities moved to compare.ts
@@ -91,9 +94,9 @@ const CompareDocumentPane = ({
return (
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{title}
<Group justify="space-between" align="center" wrap="nowrap">
<Text fw={600} size="lg" style={{ minWidth: 0, flex: 1 }} title={title || undefined}>
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
</Text>
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
@@ -516,3 +516,95 @@
margin-right: 0 !important;
}
}
/* Compare tool — file selection step (Original / Edited + swap column) */
.compare-step-selection {
width: 100%;
}
.compare-step-selection__clear-row {
display: flex;
justify-content: flex-end;
width: 100%;
}
/* Thumbnails + swap only — swap top aligns with first thumbnail, not “Original PDF” heading */
.compare-step-selection__thumbs-row {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.compare-step-selection__thumbs-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Full-height light bar to the right of both cards (reference UI) */
.compare-step-selection__swap {
flex-shrink: 0;
width: 36px;
min-width: 36px;
align-self: stretch;
margin: 0;
padding: 0.5rem 0.2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default));
background: color-mix(in srgb, var(--mantine-color-blue-0) 92%, var(--mantine-color-body));
color: var(--mantine-color-blue-filled);
cursor: pointer;
font: inherit;
transition:
background 0.12s ease,
border-color 0.12s ease;
}
.compare-step-selection__swap:hover:not(:disabled) {
background: color-mix(in srgb, var(--mantine-color-blue-1) 75%, var(--mantine-color-body));
border-color: color-mix(in srgb, var(--mantine-color-blue-5) 35%, var(--border-default));
}
.compare-step-selection__swap:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.compare-step-selection__swap-icon {
font-size: 1.35rem !important;
color: var(--mantine-color-blue-filled);
}
.compare-step-selection__swap-label {
font-size: 0.7rem;
font-weight: 600;
line-height: 1.15;
text-align: center;
color: var(--mantine-color-blue-filled);
writing-mode: horizontal-tb;
}
[data-mantine-color-scheme="dark"] .compare-step-selection__swap {
background: color-mix(in srgb, var(--mantine-color-blue-9) 22%, var(--mantine-color-dark-6));
border-color: color-mix(in srgb, var(--mantine-color-blue-4) 40%, var(--border-default));
color: var(--mantine-color-blue-3);
}
[data-mantine-color-scheme="dark"] .compare-step-selection__swap:hover:not(:disabled) {
background: color-mix(in srgb, var(--mantine-color-blue-9) 32%, var(--mantine-color-dark-6));
}
[data-mantine-color-scheme="dark"] .compare-step-selection__swap-icon,
[data-mantine-color-scheme="dark"] .compare-step-selection__swap-label {
color: var(--mantine-color-blue-3);
}
@@ -3,7 +3,7 @@ import { Stack, Text, Box, Group, ActionIcon, Center, Alert, Checkbox } from "@m
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
@@ -21,17 +21,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width
const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
const { t } = useTranslation();
const { selectedFiles, selectedFileStubs } = useSelectedFiles();
const { files, fileStubs } = useAllFiles();
// Get the first selected file for preview
// Get the first file for preview
const selectedStub = useMemo(() => {
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
}, [selectedFileStubs]);
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get the first selected file for PDF processing
// Get the first file for PDF processing
const selectedFile = useMemo(() => {
return selectedFiles.length > 0 ? selectedFiles[0] : null;
}, [selectedFiles]);
return files.length > 0 ? files[0] : null;
}, [files]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface RotateSettingsProps {
@@ -14,12 +14,12 @@ interface RotateSettingsProps {
const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => {
const { t } = useTranslation();
const { selectedFileStubs } = useSelectedFiles();
const { fileStubs } = useAllFiles();
// Get the first selected file for preview
// Get the first file for preview
const selectedStub = useMemo(() => {
return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null;
}, [selectedFileStubs]);
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
@@ -56,9 +56,9 @@ const FileStatusIndicator = ({ selectedFiles = [], minFiles = 1 }: FileStatusInd
const getPlaceholder = () => {
if (minFiles === undefined || minFiles === 1) {
return t("files.selectFromWorkbench", "Select files from the workbench or ");
return t("files.selectFromWorkbench", "Add files to the workbench or ");
} else {
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", {
return t("files.selectMultipleFromWorkbench", "Add at least {{count}} files to the workbench or ", {
count: minFiles,
});
}
@@ -136,9 +136,9 @@ const FileStatusIndicator = ({ selectedFiles = [], minFiles = 1 }: FileStatusInd
<Text size="sm" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }}>
✓{" "}
{selectedFiles.length === 1 ? (
<PrivateContent>{t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
<PrivateContent>{t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
) : (
t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
)}
</Text>
);
@@ -16,8 +16,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps {
*
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
* shows a note naming the exact file that will be processed.
* - File-editor mode with N>1 selected files: appends "(N files)" to button text.
* - File-editor mode with 0 selected files: shows a hint to select files.
* - N>1 files loaded: appends "(N files)" to button text.
* - All other cases: no change to button text or layout.
*/
export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...props }: ScopedOperationButtonProps) {
@@ -27,7 +26,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro
const { files: allFiles } = useAllFiles();
const isViewerMode = workbench === "viewer";
const isFileEditorMode = workbench === "fileEditor";
const hasMultipleFilesLoaded = allFiles.length > 1;
const baseText = props.submitText ?? t("submit", "Submit");
@@ -47,8 +45,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro
? allFiles[activeFileIndex]?.name
: null;
const showSelectFilesHint = !disableScopeHints && isFileEditorMode && allFiles.length > 0 && selectedFiles.length === 0;
return (
<>
<OperationButton {...props} submitText={scopedText} />
@@ -57,11 +53,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro
{t("tool.singleFileScope", "Only applying to: {{fileName}}", { fileName: viewerFileName })}
</Text>
)}
{showSelectFilesHint && (
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
{t("tool.selectFilesHint", "Select files in Active Files to run this tool")}
</Text>
)}
</>
);
}
@@ -99,7 +99,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) {
return (
<Center style={{ flex: 1 }}>
<Text c="dimmed" size="sm">
No file selected
No file loaded
</Text>
</Center>
);
@@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import { useFileActions, useFileManagement } from "@app/contexts/FileContext";
import { zipFileService } from "@app/services/zipFileService";
import { StirlingFileStub } from "@app/types/fileContext";
import { downloadFiles } from "@app/utils/downloadUtils";
@@ -78,6 +78,7 @@ interface FileManagerProviderProps {
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
activeFileIds: FileId[];
maxSelectable?: number | null;
}
type RemoteDeleteChoice = "local" | "server" | "both" | "leave" | "cancel";
@@ -95,10 +96,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
refreshRecentFiles,
isLoading,
activeFileIds,
maxSelectable = null,
}) => {
const [activeSource, setActiveSource] = useState<"recent" | "local" | "drive">("recent");
const [storageFilter, setStorageFilter] = useState<"all" | "local" | "sharedWithMe" | "sharedByMe">("all");
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(() => activeFileIds);
const [searchTerm, setSearchTerm] = useState("");
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
@@ -108,6 +110,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const deletePromptResolveRef = useRef<((choice: RemoteDeleteChoice) => void) | null>(null);
const { t } = useTranslation();
const { actions } = useFileActions();
const { removeFiles } = useFileManagement();
// Re-seed selection with active file IDs each time the modal opens
useEffect(() => {
if (isOpen) {
setSelectedFileIds(activeFileIds);
}
}, [isOpen]);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
@@ -234,6 +244,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedSet.delete(fileId);
} else {
selectedSet.add(fileId);
// Enforce maxSelectable: drop the oldest selection if over the limit
if (maxSelectable != null && selectedSet.size > maxSelectable) {
const [oldest] = selectedSet;
selectedSet.delete(oldest);
}
}
return Array.from(selectedSet);
@@ -243,7 +258,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setLastClickedIndex(currentIndex);
}
},
[filteredFiles, lastClickedIndex],
[filteredFiles, lastClickedIndex, maxSelectable],
);
// Helper function to safely determine which files can be deleted
@@ -551,11 +566,20 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
);
const handleOpenFiles = useCallback(() => {
if (selectedFiles.length > 0) {
onRecentFilesSelected(selectedFiles);
onClose();
// Remove active files that were unchecked
const uncheckedActiveIds = activeFileIds.filter((id) => !selectedFilesSet.has(id));
if (uncheckedActiveIds.length > 0) {
removeFiles(uncheckedActiveIds, false);
}
}, [selectedFiles, onRecentFilesSelected, onClose]);
// Add newly checked files (not already active)
const newlySelected = selectedFiles.filter((f) => !activeFileIds.includes(f.id));
if (newlySelected.length > 0) {
onRecentFilesSelected(newlySelected);
}
onClose();
}, [selectedFiles, selectedFilesSet, activeFileIds, removeFiles, onRecentFilesSelected, onClose]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
@@ -586,11 +610,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setSelectedFileIds([]);
setLastClickedIndex(null);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map((file) => file.id).filter(Boolean));
// Select all filtered files, capped at maxSelectable
const allIds = filteredFiles.map((file) => file.id).filter(Boolean) as FileId[];
setSelectedFileIds(maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds);
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds]);
}, [filteredFiles, selectedFileIds, maxSelectable]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
@@ -20,8 +20,10 @@ interface FilesModalContextType {
openFilesModal: (options?: {
insertAfterPage?: number;
customHandler?: (files: File[], insertAfterPage?: number) => void;
maxSelectable?: number | null;
}) => void;
closeFilesModal: () => void;
maxSelectable: number | null;
onFileUpload: (files: File[]) => void;
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
onModalClose?: () => void;
@@ -38,6 +40,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
const [maxSelectable, setMaxSelectable] = useState<number | null>(null);
const importBundleToWorkbench = useCallback(
async (
@@ -172,9 +175,14 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
}, []);
const openFilesModal = useCallback(
(options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
(options?: {
insertAfterPage?: number;
customHandler?: (files: File[], insertAfterPage?: number) => void;
maxSelectable?: number | null;
}) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
setMaxSelectable(options?.maxSelectable ?? null);
setIsFilesModalOpen(true);
},
[],
@@ -336,6 +344,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
onRecentFileSelect: handleRecentFileSelect,
onModalClose,
setOnModalClose: setModalCloseCallback,
maxSelectable,
}),
[
isFilesModalOpen,
@@ -345,6 +354,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
handleRecentFileSelect,
onModalClose,
setModalCloseCallback,
maxSelectable,
],
);
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
import { useSelectedFiles } from "@app/contexts/file/fileHooks";
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
interface MetadataExtractionParams {
@@ -8,7 +8,7 @@ interface MetadataExtractionParams {
}
export const useMetadataExtraction = (params: MetadataExtractionParams) => {
const { selectedFiles } = useSelectedFiles();
const selectedFiles = useViewScopedFiles();
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false);
const previousFileCountRef = useRef(0);
@@ -51,7 +51,7 @@ export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => {
const executeOperation = useCallback(
async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No files selected"));
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
@@ -95,7 +95,7 @@ export const useToolOperation = <TParams>(config: ToolOperationConfig<TParams>):
async (params: TParams, selectedFiles: StirlingFile[]): Promise<void> => {
// Validation
if (selectedFiles.length === 0) {
actions.setError(t("noFileSelected", "No files selected"));
actions.setError(t("noFileSelected", "No file loaded"));
return;
}
@@ -1,21 +1,13 @@
import { useMemo } from "react";
import { useAllFiles, useSelectedFiles } from "@app/contexts/FileContext";
import { useAllFiles } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { StirlingFile } from "@app/types/fileContext";
/**
* Returns the effective file set for tool operations.
*
* - Viewer: scopes to the single file currently shown, unless ignoreViewerScope is true.
* - FileEditor: scopes to the selected subset (empty selection → empty → button disabled).
* - PageEditor / custom workbenches: returns all loaded files (selection tracks pages, not files).
*/
export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
const { activeFileIndex } = useViewer();
const { files: allFiles } = useAllFiles();
const { workbench } = useNavigationState();
const { selectedFiles } = useSelectedFiles();
return useMemo(() => {
if (workbench === "viewer" && !ignoreViewerScope) {
@@ -23,10 +15,6 @@ export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
return viewerFile ? [viewerFile] : allFiles;
}
if (workbench === "fileEditor") {
return selectedFiles;
}
return allFiles;
}, [workbench, allFiles, activeFileIndex, selectedFiles, ignoreViewerScope]);
}, [workbench, allFiles, activeFileIndex, ignoreViewerScope]);
}
@@ -49,7 +49,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
const executeOperation = useCallback(
async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No files selected"));
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
@@ -59,7 +59,7 @@ export const useValidateSignatureOperation = (): ValidateSignatureOperationHook
const executeOperation = useCallback(
async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t("noFileSelected", "No files selected"));
setErrorMessage(t("noFileSelected", "No file loaded"));
return;
}
+161 -295
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CompareRoundedIcon from "@mui/icons-material/CompareRounded";
import CloseIcon from "@mui/icons-material/Close";
import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from "@mantine/core";
import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded";
import AddIcon from "@mui/icons-material/Add";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
@@ -15,14 +15,14 @@ import { useCompareOperation, CompareOperationHook } from "@app/hooks/tools/comp
import CompareWorkbenchView from "@app/components/tools/compare/CompareWorkbenchView";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useFileContext, useFileState } from "@app/contexts/file/fileHooks";
import type { FileId } from "@app/types/file";
import type { StirlingFile } from "@app/types/fileContext";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import type { CompareWorkbenchData } from "@app/types/compare";
import { getDefaultWorkbench } from "@app/types/workbench";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { truncateCenter } from "@app/utils/textUtils";
import { FileSelectorPicker, FileSelectorResult } from "@app/components/shared/FileSelectorPicker";
import "@app/components/tools/compare/compareView.css";
const CUSTOM_VIEW_ID = "compareWorkbenchView";
const CUSTOM_WORKBENCH_ID = "custom:compareWorkbenchView" as const;
@@ -36,12 +36,9 @@ const Compare = (props: BaseToolProps) => {
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { selectors, actions: fileActions } = useFileContext();
const { state: fileState } = useFileState();
const { openFilesModal } = useFilesModalContext();
const base = useBaseTool("compare", useCompareParameters, useCompareOperation, props, {
minFiles: 2,
minFiles: 0,
ignoreViewerScope: true,
});
@@ -51,6 +48,20 @@ const Compare = (props: BaseToolProps) => {
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
// Slot state — files loaded directly for comparison, never added to workbench
const [baseSlot, setBaseSlot] = useState<FileSelectorResult | null>(null);
const [compSlot, setCompSlot] = useState<FileSelectorResult | null>(null);
// Sync params fileIds from slots (needed for operation result matching)
useEffect(() => {
base.params.setParameters((prev) => ({
...prev,
baseFileId: baseSlot?.stirlingFile.fileId ?? null,
comparisonFileId: compSlot?.stirlingFile.fileId ?? null,
}));
}, [baseSlot?.stirlingFile.fileId, compSlot?.stirlingFile.fileId]);
const performClearSelected = useCallback(() => {
try {
base.operation.cancelOperation();
@@ -62,15 +73,11 @@ const Compare = (props: BaseToolProps) => {
} catch {
console.error("Failed to reset results");
}
base.params.setParameters((prev) => ({ ...prev, baseFileId: null, comparisonFileId: null }));
try {
fileActions.clearSelections();
} catch {
console.error("Failed to clear selections");
}
setBaseSlot(null);
setCompSlot(null);
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
navigationActions.setWorkbench(getDefaultWorkbench());
}, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]);
}, [base.operation, clearCustomWorkbenchViewData, navigationActions]);
useEffect(() => {
const handler = () => {
@@ -86,7 +93,6 @@ const Compare = (props: BaseToolProps) => {
registerCustomWorkbenchView({
id: CUSTOM_VIEW_ID,
workbenchId: CUSTOM_WORKBENCH_ID,
// Use a static label at registration time to avoid re-registering on i18n changes
label: "Compare view",
icon: compareIcon,
component: CompareWorkbenchView,
@@ -95,61 +101,9 @@ const Compare = (props: BaseToolProps) => {
return () => {
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
};
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
// Register once only
}, []);
// On mount: clear selections unless exactly 2 files are loaded.
useEffect(() => {
if ((fileState.files.ids as FileId[]).length !== 2) {
fileActions.clearSelections();
}
}, []);
// Track previous file count to detect the transition to exactly 2 files.
const prevAllIdsLengthRef = useRef<number | null>(null);
// Auto-fill slots when the file count first reaches 2; respect manual picker changes after that.
useEffect(() => {
const selectedIds = fileState.ui.selectedFileIds as FileId[];
const allIds = fileState.files.ids as FileId[];
const prevLength = prevAllIdsLengthRef.current;
prevAllIdsLengthRef.current = allIds.length;
if (allIds.length === 2 && prevLength !== 2) {
// Transitioned to exactly 2 files — auto-fill both slots.
const [firstId, secondId] = allIds as [FileId, FileId];
fileActions.setSelectedFiles([firstId, secondId]);
base.params.setParameters((prev) => {
if (prev.baseFileId === firstId && prev.comparisonFileId === secondId) return prev;
return { ...prev, baseFileId: firstId, comparisonFileId: secondId };
});
return;
}
if (selectedIds.length > 2) {
fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]);
return;
}
const nextBase = (selectedIds[0] ?? null) as FileId | null;
const nextComp = (selectedIds[1] ?? null) as FileId | null;
base.params.setParameters((prev) => {
if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp) return prev;
return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp };
});
}, [fileState.ui.selectedFileIds, fileState.files.ids]);
// Clear a slot if its file is removed from the workbench.
useEffect(() => {
const allIds = fileState.files.ids as FileId[];
if (params.baseFileId && !allIds.includes(params.baseFileId as FileId)) {
base.params.setParameters((prev) => ({ ...prev, baseFileId: null }));
}
if (params.comparisonFileId && !allIds.includes(params.comparisonFileId as FileId)) {
base.params.setParameters((prev) => ({ ...prev, comparisonFileId: null }));
}
}, [fileState.files.ids]);
// Track workbench data and drive loading/result state transitions
const lastProcessedAtRef = useRef<number | null>(null);
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(null);
@@ -175,33 +129,21 @@ const Compare = (props: BaseToolProps) => {
);
const prepareWorkbenchForRun = useCallback(
(
baseId: FileId | null,
compId: FileId | null,
options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null },
) => {
if (!baseId || !compId) {
return;
}
const previous = lastWorkbenchDataRef.current;
const resolvedBaseFile =
options?.baseFile ?? (baseId ? selectors.getFile(baseId) : null) ?? previous?.baseLocalFile ?? null;
const resolvedComparisonFile =
options?.comparisonFile ?? (compId ? selectors.getFile(compId) : null) ?? previous?.comparisonLocalFile ?? null;
(baseId: FileId | null, compId: FileId | null, baseFile: StirlingFile | null, comparisonFile: StirlingFile | null) => {
if (!baseId || !compId) return;
updateWorkbenchData({
result: null,
baseFileId: baseId,
comparisonFileId: compId,
baseLocalFile: resolvedBaseFile,
comparisonLocalFile: resolvedComparisonFile,
baseLocalFile: baseFile,
comparisonLocalFile: comparisonFile,
isLoading: true,
});
lastProcessedAtRef.current = null;
},
[selectors, updateWorkbenchData],
[updateWorkbenchData],
);
useEffect(() => {
@@ -226,15 +168,12 @@ const Compare = (props: BaseToolProps) => {
result.comparison.fileId === comparisonFileId
) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile = (baseFileId ? selectors.getFile(baseFileId) : null) ?? previous?.baseLocalFile ?? null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ?? previous?.comparisonLocalFile ?? null;
updateWorkbenchData({
result,
baseFileId,
comparisonFileId,
baseLocalFile,
comparisonLocalFile,
baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
isLoading: false,
});
lastProcessedAtRef.current = processedAt;
@@ -243,149 +182,87 @@ const Compare = (props: BaseToolProps) => {
if (base.operation.isLoading) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile = (baseFileId ? selectors.getFile(baseFileId) : null) ?? previous?.baseLocalFile ?? null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ?? previous?.comparisonLocalFile ?? null;
updateWorkbenchData({
result: null,
baseFileId,
comparisonFileId,
baseLocalFile,
comparisonLocalFile,
baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
isLoading: true,
});
return;
}
}, [
base.operation.isLoading,
baseSlot,
clearCustomWorkbenchViewData,
compSlot,
operation.result,
params.baseFileId,
params.comparisonFileId,
selectors,
updateWorkbenchData,
]);
const handleExecuteCompare = useCallback(async () => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
const baseSel = base.selectedFiles.find((file) => file.fileId === baseId) ?? (baseId ? selectors.getFile(baseId) : null);
const compSel = base.selectedFiles.find((file) => file.fileId === compId) ?? (compId ? selectors.getFile(compId) : null);
const selected: StirlingFile[] = [];
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
if (!baseSlot || !compSlot) return;
const baseId = baseSlot.stirlingFile.fileId;
const compId = compSlot.stirlingFile.fileId;
const selected: StirlingFile[] = [baseSlot.stirlingFile, compSlot.stirlingFile];
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
if (baseId && compId) {
prepareWorkbenchForRun(baseId, compId, baseSlot.stirlingFile, compSlot.stirlingFile);
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
await operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected);
}, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]);
const performSwap = useCallback(() => {
if (!baseSlot || !compSlot) return;
const newBase = compSlot;
const newComp = baseSlot;
setBaseSlot(newBase);
setCompSlot(newComp);
if (operation.result) {
const baseId = newBase.stirlingFile.fileId;
const compId = newComp.stirlingFile.fileId;
const selected: StirlingFile[] = [newBase.stirlingFile, newComp.stirlingFile];
prepareWorkbenchForRun(baseId, compId, newBase.stirlingFile, newComp.stirlingFile);
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
void operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected);
}
await operation.executeOperation({ ...params }, selected);
}, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, selectors]);
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
const runCompareWithIds = useCallback(
async (baseId: FileId | null, compId: FileId | null) => {
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
const selected: StirlingFile[] = [];
const baseSel = base.selectedFiles.find((file) => file.fileId === baseId) ?? (baseId ? selectors.getFile(baseId) : null);
const compSel = base.selectedFiles.find((file) => file.fileId === compId) ?? (compId ? selectors.getFile(compId) : null);
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
await operation.executeOperation(nextParams, selected);
},
[base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors],
);
const performSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
if (!baseId || !compId) return;
base.params.setParameters((prev) => ({
...prev,
baseFileId: compId,
comparisonFileId: baseId,
}));
if (operation.result) {
runCompareWithIds(compId, baseId);
}
}, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]);
// No custom handler; rely on global add flow which auto-selects added files
}, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]);
const handleSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
if (!baseId || !compId) return;
if (!baseSlot || !compSlot) return;
if (operation.result) {
setSwapConfirmOpen(true);
return;
}
performSwap();
}, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]);
}, [baseSlot, compSlot, operation.result, performSwap]);
const renderSelectedFile = useCallback(
const clearSlot = useCallback((role: "base" | "comparison") => {
if (role === "base") setBaseSlot(null);
else setCompSlot(null);
}, []);
const renderSlot = useCallback(
(role: "base" | "comparison") => {
const fileId = role === "base" ? params.baseFileId : params.comparisonFileId;
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
const slot = role === "base" ? baseSlot : compSlot;
const otherSlot = role === "base" ? compSlot : baseSlot;
const stub = slot?.stub;
// Show add button in base if no base file, or in comparison if base exists but no comparison
const shouldShowAddButton =
(role === "base" && !params.baseFileId) || (role === "comparison" && params.baseFileId && !params.comparisonFileId);
if (stub) {
const dateMs = (stub.lastModified || stub.createdAt) ?? null;
const dateText = dateMs
? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" })
: "";
const pageCount = stub.processedFile?.totalPages || null;
if (!stub) {
return (
<Stack gap={6}>
<Box
style={{
border: "1px solid var(--border-default)",
borderRadius: "var(--radius-md)",
padding: "0.75rem 1rem",
background: "var(--bg-surface)",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: shouldShowAddButton ? "pointer" : "default",
}}
onClick={shouldShowAddButton ? () => openFilesModal({}) : undefined}
>
<Text size="sm" c="dimmed">
{t(
role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder",
role === "base" ? "Select the original PDF" : "Select the edited PDF",
)}
</Text>
{shouldShowAddButton && (
<ActionIcon
variant="filled"
color="blue"
size="sm"
onClick={(e) => {
e.stopPropagation();
openFilesModal({});
}}
style={{ flexShrink: 0 }}
>
<AddIcon fontSize="small" />
</ActionIcon>
)}
</Box>
</Stack>
);
}
// Build compact meta line for pages and date
const dateMs = (stub?.lastModified || stub?.createdAt) ?? null;
const dateText = dateMs
? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" })
: "";
const pageCount = stub?.processedFile?.totalPages || null;
return (
<Stack gap={6}>
<Box
style={{
border: "1px solid var(--border-default)",
@@ -394,48 +271,73 @@ const Compare = (props: BaseToolProps) => {
background: "var(--bg-surface)",
width: "100%",
minHeight: "9rem",
position: "relative",
}}
>
<ActionIcon
variant="subtle"
size="xs"
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
onClick={() => clearSlot(role)}
aria-label={t("compare.clearSlot", "Remove file")}
>
<CloseIcon fontSize="small" />
</ActionIcon>
<Group align="flex-start" wrap="nowrap" gap="md">
<Box className="compare-tool__thumbnail" style={{ alignSelf: "center" }}>
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
<Box style={{ alignSelf: "center" }}>
<DocumentThumbnail file={stub} thumbnail={stub.thumbnailUrl || null} />
</Box>
<Stack className="compare-tool__details" style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
<Text fw={600} title={stub?.name}>
{truncateCenter(stub?.name || "", 50)}
<Stack style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
<Text fw={600} title={stub.name}>
{truncateCenter(stub.name || "", 50)}
</Text>
{pageCount && dateText && (
<>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{pageCount} {t("compare.pages", "pages")}
<br />
{dateText}
</Text>
</>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{pageCount} {t("compare.pages", "pages")}
<br />
{dateText}
</Text>
)}
</Stack>
</Group>
</Box>
</Stack>
);
}
const isDisabled = role === "comparison" && !baseSlot;
return (
<FileSelectorPicker
placeholder={
isDisabled
? t("compare.edited.selectBaseFirst", "Select original PDF first")
: t(
role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder",
role === "base" ? "Select the original PDF" : "Select the edited PDF",
)
}
excludeIds={otherSlot ? [otherSlot.stirlingFile.fileId as string] : []}
disabled={isDisabled}
onSelect={(result: FileSelectorResult) => {
if (role === "base") setBaseSlot(result);
else setCompSlot(result);
}}
/>
);
},
[params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal],
[baseSlot, compSlot, clearSlot, t],
);
const baseStub = params.baseFileId ? selectors.getStirlingFileStub(params.baseFileId) : undefined;
const compStub = params.comparisonFileId ? selectors.getStirlingFileStub(params.comparisonFileId) : undefined;
const canExecute = Boolean(
params.baseFileId &&
params.comparisonFileId &&
params.baseFileId !== params.comparisonFileId &&
baseStub &&
compStub &&
baseSlot &&
compSlot &&
baseSlot.stirlingFile.fileId !== compSlot.stirlingFile.fileId &&
!base.operation.isLoading &&
base.endpointEnabled !== false,
);
const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId);
const hasAnyFiles = selectors.getFiles().length > 0;
const hasBothSelected = Boolean(baseSlot && compSlot);
const hasAnySelected = Boolean(baseSlot || compSlot);
return createToolFlow({
files: {
@@ -447,87 +349,50 @@ const Compare = (props: BaseToolProps) => {
title: t("compare.selection.originalEditedTitle", "Select Original and Edited PDFs"),
isVisible: true,
content: (
<Box
style={{
display: "grid",
gridTemplateColumns: hasBothSelected ? "1fr 2.25rem" : "1fr",
gap: "1rem",
alignItems: "stretch",
width: "100%",
}}
>
{/* Header row: Original PDF + Clear selected aligned to swap column */}
<Box
style={{
gridColumn: hasBothSelected ? "1 / span 2" : "1",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "0.5rem",
}}
>
<Text fw={700} size="sm">
{t("compare.original.label", "Original PDF")}
</Text>
<Stack gap="sm" className="compare-step-selection">
<div className="compare-step-selection__clear-row">
<Button
variant="subtle"
size="compact-xs"
onClick={() => setClearConfirmOpen(true)}
disabled={!hasAnyFiles}
disabled={!hasAnySelected}
styles={{ root: { textDecoration: "underline" } }}
style={{
background: !hasAnyFiles ? "transparent" : undefined,
color: !hasAnyFiles ? "var(--spdf-clear-disabled-text)" : undefined,
background: !hasAnySelected ? "transparent" : undefined,
color: !hasAnySelected ? "var(--spdf-clear-disabled-text)" : undefined,
}}
>
{t("compare.clearSelected", "Clear selected")}
</Button>
</Box>
<Box
style={{
gridColumn: "1",
minWidth: 0,
}}
>
{renderSelectedFile("base")}
<div style={{ height: "0.75rem" }} />
{/* Edited PDF section header */}
<Text fw={700} size="sm" style={{ marginBottom: "1rem", marginTop: "0.5rem" }}>
{t("compare.edited.label", "Edited PDF")}
</Text>
{renderSelectedFile("comparison")}
</Box>
{hasBothSelected && (
<Box
style={{
gridColumn: "2",
gridRow: "2",
display: "flex",
alignItems: "center",
justifyContent: "center",
alignSelf: "stretch",
}}
>
<Button
variant="subtle"
</div>
<Text fw={700} size="sm" style={{ margin: 0 }}>
{t("compare.original.label", "Original PDF")}
</Text>
<div className="compare-step-selection__thumbs-row">
<Stack gap="sm" className="compare-step-selection__thumbs-col">
{renderSlot("base")}
<Text fw={700} size="sm" style={{ margin: 0 }}>
{t("compare.edited.label", "Edited PDF")}
</Text>
{renderSlot("comparison")}
</Stack>
{hasBothSelected && (
<button
type="button"
className="compare-step-selection__swap"
onClick={handleSwap}
disabled={!hasBothSelected || base.operation.isLoading}
style={{
width: "2.25rem",
height: "100%",
padding: 0,
borderRadius: "0.5rem",
background: "var(--bg-surface)",
border: "1px solid var(--border-default)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
disabled={base.operation.isLoading}
aria-label={t("compare.swap.label", "Swap")}
>
<SwapVertRoundedIcon fontSize="medium" />
</Button>
</Box>
)}
<SwapVertRoundedIcon className="compare-step-selection__swap-icon" fontSize="inherit" />
<span className="compare-step-selection__swap-label">{t("compare.swap.label", "Swap")}</span>
</button>
)}
</div>
<Modal
opened={swapConfirmOpen}
onClose={() => setSwapConfirmOpen(false)}
@@ -558,6 +423,7 @@ const Compare = (props: BaseToolProps) => {
</Group>
</Stack>
</Modal>
<Modal
opened={clearConfirmOpen}
onClose={() => setClearConfirmOpen(false)}
@@ -566,9 +432,7 @@ const Compare = (props: BaseToolProps) => {
size="sm"
>
<Stack gap="md">
<Text>
{t("compare.clear.confirmBody", "This will close the current comparison and take you back to Active Files.")}
</Text>
<Text>{t("compare.clear.confirmBody", "This will clear the current file selections.")}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>
{t("cancel", "Cancel")}
@@ -580,12 +444,12 @@ const Compare = (props: BaseToolProps) => {
performClearSelected();
}}
>
{t("compare.clear.confirm", "Clear and return")}
{t("compare.clear.confirm", "Clear")}
</Button>
</Group>
</Stack>
</Modal>
</Box>
</Stack>
),
},
],
@@ -594,6 +458,8 @@ const Compare = (props: BaseToolProps) => {
loadingText: t("compare.loading", "Comparing..."),
onClick: handleExecuteCompare,
disabled: !canExecute,
// Explicitly null so the noFiles gate is bypassed — Compare manages its own slot state
disabledReason: base.endpointEnabled === false ? "endpointUnavailable" : null,
testId: "compare-execute",
disableScopeHints: true,
},
+4 -5
View File
@@ -9,7 +9,7 @@ import { useMergeOperation } from "@app/hooks/tools/merge/useMergeOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useMergeTips } from "@app/components/tooltips/useMergeTips";
import { useFileManagement, useSelectedFiles, useAllFiles } from "@app/contexts/FileContext";
import { useFileManagement, useAllFiles } from "@app/contexts/FileContext";
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
const Merge = (props: BaseToolProps) => {
@@ -17,8 +17,7 @@ const Merge = (props: BaseToolProps) => {
const mergeTips = useMergeTips();
// File selection hooks for custom sorting
const { fileIds } = useAllFiles();
const { selectedFileStubs } = useSelectedFiles();
const { fileIds, fileStubs } = useAllFiles();
const { reorderFiles } = useFileManagement();
const base = useBaseTool("merge", useMergeParameters, useMergeOperation, props, { minFiles: 2, ignoreViewerScope: true });
@@ -89,7 +88,7 @@ const Merge = (props: BaseToolProps) => {
// Custom file sorting logic for merge tool
const sortFiles = useCallback(
(sortType: "filename" | "dateModified", ascending: boolean = true) => {
const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => {
const sortedStubs = [...fileStubs].sort((stubA, stubB) => {
let comparison = 0;
switch (sortType) {
case "filename":
@@ -106,7 +105,7 @@ const Merge = (props: BaseToolProps) => {
const deselectedIds = fileIds.filter((id) => !selectedIds.includes(id));
reorderFiles([...selectedIds, ...deselectedIds]);
},
[selectedFileStubs, fileIds, reorderFiles, naturalCompare],
[fileStubs, fileIds, reorderFiles, naturalCompare],
);
return createToolFlow({