Files
Stirling-PDF/frontend/src/core/contexts/FileManagerContext.tsx
T
James BruntonandGitHub a3e45bc182 Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
2026-04-10 17:41:19 +01:00

1017 lines
36 KiB
TypeScript

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 { zipFileService } from "@app/services/zipFileService";
import { StirlingFileStub } from "@app/types/fileContext";
import { downloadFiles } from "@app/utils/downloadUtils";
import { FileId } from "@app/types/file";
import { groupFilesByOriginal } from "@app/utils/fileHistoryUtils";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { extractLatestFilesFromBundle, parseContentDispositionFilename } from "@app/services/shareBundleUtils";
import { useTranslation } from "react-i18next";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
export { pendingFilePathMappings } from "@app/services/pendingFilePathMappings";
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
// State
activeSource: "recent" | "local" | "drive";
storageFilter: "all" | "local" | "sharedWithMe" | "sharedByMe";
selectedFileIds: FileId[];
searchTerm: string;
selectedFiles: StirlingFileStub[];
filteredFiles: StirlingFileStub[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<FileId>;
expandedFileIds: Set<FileId>;
fileGroups: Map<FileId, StirlingFileStub[]>;
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
isLoading: boolean;
activeFileIds: FileId[];
// Handlers
onSourceChange: (source: "recent" | "local" | "drive") => void;
onStorageFilterChange: (filter: "all" | "local" | "sharedWithMe" | "sharedByMe") => void;
onLocalFileClick: () => void;
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onHistoryFileRemove: (file: StirlingFileStub) => void;
onFileDoubleClick: (file: StirlingFileStub) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: StirlingFileStub) => void;
onToggleExpansion: (fileId: FileId) => void;
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
onMakeCopy: (file: StirlingFileStub) => Promise<void>;
onNewFilesSelect: (files: File[]) => void;
onGoogleDriveSelect: (files: File[]) => void;
refreshRecentFiles: () => Promise<void>;
// External props
recentFiles: StirlingFileStub[];
isFileSupported: (fileName: string) => boolean;
modalHeight: string;
}
// Create the context
const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Provider component props
interface FileManagerProviderProps {
children: React.ReactNode;
recentFiles: StirlingFileStub[];
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
onClose: () => void;
isFileSupported: (fileName: string) => boolean;
isOpen: boolean;
onFileRemove: (index: number) => void;
modalHeight: string;
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
activeFileIds: FileId[];
}
type RemoteDeleteChoice = "local" | "server" | "both" | "leave" | "cancel";
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
children,
recentFiles,
onRecentFilesSelected,
onNewFilesSelect,
onClose,
isFileSupported,
isOpen,
onFileRemove,
modalHeight,
refreshRecentFiles,
isLoading,
activeFileIds,
}) => {
const [activeSource, setActiveSource] = useState<"recent" | "local" | "drive">("recent");
const [storageFilter, setStorageFilter] = useState<"all" | "local" | "sharedWithMe" | "sharedByMe">("all");
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
const fileInputRef = useRef<HTMLInputElement>(null);
const [deletePromptFile, setDeletePromptFile] = useState<StirlingFileStub | null>(null);
const deletePromptResolveRef = useRef<((choice: RemoteDeleteChoice) => void) | null>(null);
const { t } = useTranslation();
const { actions } = useFileActions();
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
// Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds);
// Group files by original file ID for version management
const fileGroups = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return new Map();
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
const recordsForGrouping = recentFiles.map((file) => ({
...file,
originalFileId: file.originalFileId,
versionNumber: file.versionNumber || 1,
}));
return groupFilesByOriginal(recordsForGrouping);
}, [recentFiles]);
// Get files to display with expansion logic
const displayFiles = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return [];
// Only return leaf files - history files will be handled by separate components
if (storageFilter === "sharedWithMe") {
return recentFiles.filter((file) => file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
}
if (storageFilter === "sharedByMe") {
return recentFiles.filter((file) => file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks);
}
if (storageFilter === "local") {
return recentFiles.filter((file) => !file.remoteStorageId && !file.remoteSharedViaLink);
}
return recentFiles;
}, [recentFiles, storageFilter]);
const selectedFiles = selectedFileIds.length === 0 ? [] : displayFiles.filter((file) => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm
? displayFiles
: displayFiles.filter((file) => file.name.toLowerCase().includes(searchTerm.toLowerCase()));
const handleSourceChange = useCallback((source: "recent" | "local" | "drive") => {
setActiveSource(source);
if (source !== "recent") {
setSelectedFileIds([]);
setSearchTerm("");
setLastClickedIndex(null);
}
}, []);
const handleStorageFilterChange = useCallback((filter: "all" | "local" | "sharedWithMe" | "sharedByMe") => {
setStorageFilter(filter);
setSelectedFileIds([]);
setLastClickedIndex(null);
}, []);
const requestDeleteChoice = useCallback((file: StirlingFileStub): Promise<RemoteDeleteChoice> => {
return new Promise((resolve) => {
deletePromptResolveRef.current = resolve;
setDeletePromptFile(file);
});
}, []);
const resolveDeleteChoice = useCallback((choice: RemoteDeleteChoice) => {
const resolver = deletePromptResolveRef.current;
deletePromptResolveRef.current = null;
setDeletePromptFile(null);
resolver?.(choice);
}, []);
const handleLocalFileClick = useCallback(async () => {
console.log("[FileManager] Opening file dialog...");
// Try native dialog first (desktop), falls back to empty array (web)
const files = await openFilesFromDisk({
multiple: true,
onFallbackOpen: () => fileInputRef.current?.click(),
});
if (files.length > 0) {
console.log(
"[FileManager] Passing files to FileContext:",
files.map((f) => f.name),
);
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
}
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
const handleFileSelect = useCallback(
(file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
if (!fileId) return;
if (shiftKey && lastClickedIndex !== null) {
// Range selection with shift-click
const startIndex = Math.min(lastClickedIndex, currentIndex);
const endIndex = Math.max(lastClickedIndex, currentIndex);
setSelectedFileIds((prev) => {
const selectedSet = new Set(prev);
// Add all files in the range to selection
for (let i = startIndex; i <= endIndex; i++) {
const rangeFileId = filteredFiles[i]?.id;
if (rangeFileId) {
selectedSet.add(rangeFileId);
}
}
return Array.from(selectedSet);
});
} else {
// Normal click behavior - optimized with Set for O(1) lookup
setSelectedFileIds((prev) => {
const selectedSet = new Set(prev);
if (selectedSet.has(fileId)) {
selectedSet.delete(fileId);
} else {
selectedSet.add(fileId);
}
return Array.from(selectedSet);
});
// Update last clicked index for future range selections
setLastClickedIndex(currentIndex);
}
},
[filteredFiles, lastClickedIndex],
);
// Helper function to safely determine which files can be deleted
const getSafeFilesToDelete = useCallback((fileIds: FileId[], allStoredStubs: StirlingFileStub[]): FileId[] => {
const fileMap = new Map(allStoredStubs.map((f) => [f.id, f]));
const filesToDelete = new Set<FileId>();
const filesToPreserve = new Set<FileId>();
// First, identify all files in the lineages of the leaf files being deleted
for (const leafFileId of fileIds) {
const currentFile = fileMap.get(leafFileId);
if (!currentFile) continue;
// Always include the leaf file itself for deletion
filesToDelete.add(leafFileId);
// If this is a processed file with history, trace back through its lineage
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
const originalFileId = currentFile.originalFileId || currentFile.id;
// Find all files in this history chain
const chainFiles = allStoredStubs.filter(
(file: StirlingFileStub) => (file.originalFileId || file.id) === originalFileId,
);
// Add all files in this lineage as candidates for deletion
chainFiles.forEach((file) => filesToDelete.add(file.id));
}
}
// Now identify files that must be preserved because they're referenced by OTHER lineages
for (const file of allStoredStubs) {
const fileOriginalId = file.originalFileId || file.id;
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
if (file.isLeaf !== false && !fileIds.includes(file.id)) {
// Find all files in this preserved lineage
const preservedChainFiles = allStoredStubs.filter(
(chainFile: StirlingFileStub) => (chainFile.originalFileId || chainFile.id) === fileOriginalId,
);
// Mark all files in this preserved lineage as must-preserve
preservedChainFiles.forEach((chainFile) => filesToPreserve.add(chainFile.id));
}
}
// Final list: files to delete minus files that must be preserved
let safeToDelete = Array.from(filesToDelete).filter((fileId) => !filesToPreserve.has(fileId));
// Check for orphaned non-leaf files after main deletion
const remainingFiles = allStoredStubs.filter((file) => !safeToDelete.includes(file.id));
const orphanedNonLeafFiles: FileId[] = [];
for (const file of remainingFiles) {
// Only check non-leaf files (files that have been processed and have children)
if (file.isLeaf === false) {
const fileOriginalId = file.originalFileId || file.id;
// Check if this non-leaf file has any living descendants
const hasLivingDescendants = remainingFiles.some((otherFile) => {
// Check if otherFile is a descendant of this file
const otherOriginalId = otherFile.originalFileId || otherFile.id;
return (
// Direct parent relationship
otherFile.parentFileId === file.id ||
// Same lineage but different from this file
(otherOriginalId === fileOriginalId && otherFile.id !== file.id)
);
});
if (!hasLivingDescendants) {
orphanedNonLeafFiles.push(file.id);
}
}
}
// Add orphaned non-leaf files to deletion list
safeToDelete = [...safeToDelete, ...orphanedNonLeafFiles];
return safeToDelete;
}, []);
// Shared internal delete logic
const performFileDelete = useCallback(
async (fileToRemove: StirlingFileStub, fileIndex: number) => {
let deleteChoice: RemoteDeleteChoice = "local";
if (fileToRemove.remoteStorageId) {
deleteChoice = await requestDeleteChoice(fileToRemove);
if (deleteChoice === "cancel") {
return;
}
}
const canDeleteServer = fileToRemove.remoteOwnedByCurrentUser !== false;
const shouldDeleteServer = canDeleteServer && (deleteChoice === "server" || deleteChoice === "both");
const shouldDeleteLocal = deleteChoice === "local" || deleteChoice === "both";
const isServerOnly = Boolean(fileToRemove.remoteStorageId) && fileToRemove.id.startsWith("server-");
const canLeaveShare =
fileToRemove.remoteOwnedByCurrentUser === false &&
!fileToRemove.remoteSharedViaLink &&
Boolean(fileToRemove.remoteStorageId);
if (deleteChoice === "leave" && canLeaveShare && fileToRemove.remoteStorageId) {
try {
await apiClient.delete(`/api/v1/storage/files/${fileToRemove.remoteStorageId}/shares/self`, {
suppressErrorToast: true,
} as any);
await refreshRecentFiles();
alert({
alertType: "success",
title: t("fileManager.leaveShareSuccess", "Removed from your shared list."),
expandable: false,
durationMs: 2500,
});
return;
} catch (error) {
console.error("Failed to leave shared file:", error);
alert({
alertType: "error",
title: t("fileManager.leaveShareFailed", "Could not remove the shared file."),
expandable: false,
durationMs: 3500,
});
return;
}
}
if (shouldDeleteServer && fileToRemove.remoteStorageId) {
try {
await apiClient.delete(`/api/v1/storage/files/${fileToRemove.remoteStorageId}`, { suppressErrorToast: true } as any);
} catch (error) {
console.error("Failed to delete file from server:", error);
alert({
alertType: "error",
title: t("fileManager.removeServerFailed", "Could not remove the file from the server."),
expandable: false,
durationMs: 3500,
});
return;
}
if (!shouldDeleteLocal) {
const originalFileId = (fileToRemove.originalFileId || fileToRemove.id) as FileId;
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
for (const stub of chain) {
await fileStorage.updateFileMetadata(stub.id, {
remoteStorageId: undefined,
remoteStorageUpdatedAt: undefined,
});
}
await refreshRecentFiles();
alert({
alertType: "success",
title: t("fileManager.removeServerSuccess", "Removed from server."),
expandable: false,
durationMs: 2500,
});
return;
}
}
if (shouldDeleteLocal && isServerOnly) {
await refreshRecentFiles();
return;
}
const deletedFileId = fileToRemove.id;
// Get all stored files to analyze lineages
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
// Get safe files to delete (respecting shared lineages)
const filesToDelete = getSafeFilesToDelete([deletedFileId], allStoredStubs);
// Clear from selection immediately
setSelectedFileIds((prev) => prev.filter((id) => !filesToDelete.includes(id)));
// Clear from expanded state to prevent ghost entries
setExpandedFileIds((prev) => {
const newExpanded = new Set(prev);
filesToDelete.forEach((id) => newExpanded.delete(id));
return newExpanded;
});
// Clear from history cache - remove all files in the chain
setLoadedHistoryFiles((prev) => {
const newCache = new Map(prev);
// Remove cache entries for all deleted files
filesToDelete.forEach((id) => newCache.delete(id as FileId));
// Also remove deleted files from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) {
const filteredHistory = historyFiles.filter((histFile) => !filesToDelete.includes(histFile.id));
if (filteredHistory.length !== historyFiles.length) {
newCache.set(mainFileId, filteredHistory);
}
}
return newCache;
});
// Delete safe files from IndexedDB
try {
for (const fileId of filesToDelete) {
await fileStorage.deleteStirlingFile(fileId as FileId);
}
} catch (error) {
console.error("Failed to delete files from chain:", error);
}
// Call the parent's deletion logic for the main file only
onFileRemove(fileIndex);
// Refresh to ensure consistent state
await refreshRecentFiles();
},
[
getSafeFilesToDelete,
setSelectedFileIds,
setExpandedFileIds,
setLoadedHistoryFiles,
onFileRemove,
refreshRecentFiles,
requestDeleteChoice,
t,
],
);
const handleFileRemove = useCallback(
async (index: number) => {
const fileToRemove = filteredFiles[index];
if (fileToRemove) {
await performFileDelete(fileToRemove, index);
}
},
[filteredFiles, performFileDelete],
);
// Handle deletion by fileId (more robust than index-based)
const handleFileRemoveById = useCallback(
async (fileId: FileId) => {
// Find the file and its index in filteredFiles
const fileIndex = filteredFiles.findIndex((file) => file.id === fileId);
const fileToRemove = filteredFiles[fileIndex];
if (fileToRemove && fileIndex !== -1) {
await performFileDelete(fileToRemove, fileIndex);
}
},
[filteredFiles, performFileDelete],
);
// Handle deletion of specific history files (not index-based)
const handleHistoryFileRemove = useCallback(
async (fileToRemove: StirlingFileStub) => {
const deletedFileId = fileToRemove.id;
// Clear from expanded state to prevent ghost entries
setExpandedFileIds((prev) => {
const newExpanded = new Set(prev);
newExpanded.delete(deletedFileId);
return newExpanded;
});
// Clear from history cache - remove all files in the chain
setLoadedHistoryFiles((prev) => {
const newCache = new Map(prev);
// Remove cache entries for all deleted files
newCache.delete(deletedFileId);
// Also remove deleted files from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) {
const filteredHistory = historyFiles.filter((histFile) => deletedFileId != histFile.id);
if (filteredHistory.length !== historyFiles.length) {
newCache.set(mainFileId, filteredHistory);
}
}
return newCache;
});
// Delete safe files from IndexedDB
try {
await fileStorage.deleteStirlingFile(deletedFileId);
} catch (error) {
console.error("Failed to delete files from chain:", error);
}
// Refresh to ensure consistent state
await refreshRecentFiles();
},
[filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete],
);
const handleFileDoubleClick = useCallback(
(file: StirlingFileStub) => {
if (isFileSupported(file.name)) {
onRecentFilesSelected([file]);
onClose();
}
},
[isFileSupported, onRecentFilesSelected, onClose],
);
const handleOpenFiles = useCallback(() => {
if (selectedFiles.length > 0) {
onRecentFilesSelected(selectedFiles);
onClose();
}
}, [selectedFiles, onRecentFilesSelected, onClose]);
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
}, []);
const handleFileInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
try {
// For local file uploads, pass File objects directly to FileContext
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
} catch (error) {
console.error("Failed to process selected files:", error);
}
}
event.target.value = "";
},
[onNewFilesSelect, refreshRecentFiles, onClose],
);
const handleSelectAll = useCallback(() => {
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
if (allFilesSelected) {
// Deselect all
setSelectedFileIds([]);
setLastClickedIndex(null);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map((file) => file.id).filter(Boolean));
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Delete each selected file using the proven single delete logic
for (const fileId of selectedFileIds) {
await handleFileRemoveById(fileId);
}
} catch (error) {
console.error("Failed to delete selected files:", error);
}
}, [selectedFileIds, handleFileRemoveById]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get selected files
const selectedFilesToDownload = filteredFiles.filter((file) => selectedFileIds.includes(file.id));
// Use generic download utility
await downloadFiles(selectedFilesToDownload, {
zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, "")}.zip`,
});
} catch (error) {
console.error("Failed to download selected files:", error);
}
}, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
try {
await downloadFiles([file]);
} catch (error) {
console.error("Failed to download file:", error);
}
}, []);
const handleToggleExpansion = useCallback(
async (fileId: FileId) => {
const isCurrentlyExpanded = expandedFileIds.has(fileId);
// Update expansion state
setExpandedFileIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(fileId)) {
newSet.delete(fileId);
} else {
newSet.add(fileId);
}
return newSet;
});
// Load complete history chain if expanding
if (!isCurrentlyExpanded) {
const currentFileMetadata = recentFiles.find((f) => f.id === fileId);
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
try {
// Get all stored file metadata for chain traversal
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
const fileMap = new Map(allStoredStubs.map((f) => [f.id, f]));
// Get the current file's IndexedDB data
const currentStoredStub = fileMap.get(fileId as FileId);
if (!currentStoredStub) {
console.warn(`No stored file found for ${fileId}`);
return;
}
// Build complete history chain using IndexedDB metadata
const historyFiles: StirlingFileStub[] = [];
// Find the original file
// Collect only files in this specific branch (ancestors of current file)
const chainFiles: StirlingFileStub[] = [];
const allFiles = Array.from(fileMap.values());
// Build a map for fast parent lookups
const fileIdMap = new Map<FileId, StirlingFileStub>();
allFiles.forEach((f) => fileIdMap.set(f.id, f));
// Trace back from current file through parent chain
let currentFile = fileIdMap.get(fileId);
while (currentFile?.parentFileId) {
const parentFile = fileIdMap.get(currentFile.parentFileId);
if (parentFile) {
chainFiles.push(parentFile);
currentFile = parentFile;
} else {
break; // Parent not found, stop tracing
}
}
// Sort by version number (oldest first for history display)
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
// StirlingFileStubs already have all the data we need - no conversion required!
historyFiles.push(...chainFiles);
// Cache the loaded history files
setLoadedHistoryFiles((prev) => new Map(prev.set(fileId as FileId, historyFiles)));
} catch (error) {
console.warn(`Failed to load history chain for file ${fileId}:`, error);
}
}
} else {
// Clear loaded history when collapsing
setLoadedHistoryFiles((prev) => {
const newMap = new Map(prev);
newMap.delete(fileId as FileId);
return newMap;
});
}
},
[expandedFileIds, recentFiles],
);
const handleAddToRecents = useCallback(
async (file: StirlingFileStub) => {
try {
// Mark the file as a leaf node so it appears in recent files
await fileStorage.markFileAsLeaf(file.id);
// Refresh the recent files list to show updated state
await refreshRecentFiles();
} catch (error) {
console.error("Failed to add to recents:", error);
}
},
[refreshRecentFiles],
);
const handleGoogleDriveSelect = useCallback(
async (files: File[]) => {
if (files.length > 0) {
try {
// Process Google Drive files same as local files
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
} catch (error) {
console.error("Failed to process Google Drive files:", error);
}
}
},
[onNewFilesSelect, refreshRecentFiles, onClose],
);
const handleUnzipFile = useCallback(
async (file: StirlingFileStub) => {
try {
// Load the full file from storage
const stirlingFile = await fileStorage.getStirlingFile(file.id);
if (!stirlingFile) {
return;
}
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(stirlingFile, file);
if (result.success) {
// Refresh file manager to show new files
await refreshRecentFiles();
}
if (result.errors.length > 0) {
console.error("Errors during unzip:", result.errors);
}
} catch (error) {
console.error("Failed to unzip file:", error);
}
},
[refreshRecentFiles],
);
const handleMakeCopy = useCallback(
async (file: StirlingFileStub) => {
if (!file.remoteStorageId) {
return;
}
try {
const response = await apiClient.get(`/api/v1/storage/files/${file.remoteStorageId}/download`, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
} as any);
const contentType = (response.headers && (response.headers["content-type"] || response.headers["Content-Type"])) || "";
const disposition =
(response.headers && (response.headers["content-disposition"] || response.headers["Content-Disposition"])) || "";
const filename = parseContentDispositionFilename(disposition) || file.name || "shared-file";
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
const latestFiles = await extractLatestFilesFromBundle(blob, filename, contentTypeValue);
if (latestFiles.length > 0) {
await actions.addFilesWithOptions(latestFiles, {
selectFiles: true,
allowDuplicates: true,
autoUnzip: false,
skipAutoUnzip: false,
});
await refreshRecentFiles();
alert({
alertType: "success",
title: t("fileManager.copyCreated", "Copy saved to this device."),
expandable: false,
durationMs: 2500,
});
}
} catch (error) {
console.error("Failed to create a copy:", error);
alert({
alertType: "error",
title: t("fileManager.copyFailed", "Could not create a copy."),
expandable: false,
durationMs: 3500,
});
}
},
[actions, refreshRecentFiles, t],
);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
// Clean up all created blob URLs
createdBlobUrls.current.forEach((url) => {
URL.revokeObjectURL(url);
});
createdBlobUrls.current.clear();
};
}, []);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setActiveSource("recent");
setStorageFilter("all");
setSelectedFileIds([]);
setSearchTerm("");
setLastClickedIndex(null);
}
}, [isOpen]);
const contextValue: FileManagerContextValue = useMemo(
() => ({
// State
activeSource,
storageFilter,
selectedFileIds,
searchTerm,
selectedFiles,
filteredFiles,
fileInputRef,
selectedFilesSet,
expandedFileIds,
fileGroups,
loadedHistoryFiles,
isLoading,
activeFileIds,
// Handlers
onSourceChange: handleSourceChange,
onStorageFilterChange: handleStorageFilterChange,
onLocalFileClick: handleLocalFileClick,
onFileSelect: handleFileSelect,
onFileRemove: handleFileRemove,
onHistoryFileRemove: handleHistoryFileRemove,
onFileDoubleClick: handleFileDoubleClick,
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
onToggleExpansion: handleToggleExpansion,
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onMakeCopy: handleMakeCopy,
onNewFilesSelect,
onGoogleDriveSelect: handleGoogleDriveSelect,
refreshRecentFiles,
// External props
recentFiles,
isFileSupported,
modalHeight,
}),
[
activeSource,
storageFilter,
selectedFileIds,
searchTerm,
selectedFiles,
filteredFiles,
fileInputRef,
expandedFileIds,
fileGroups,
loadedHistoryFiles,
isLoading,
activeFileIds,
handleSourceChange,
handleStorageFilterChange,
handleLocalFileClick,
handleFileSelect,
handleFileRemove,
handleFileRemoveById,
performFileDelete,
handleFileDoubleClick,
handleOpenFiles,
handleSearchChange,
handleFileInputChange,
handleSelectAll,
handleDeleteSelected,
handleDownloadSelected,
handleToggleExpansion,
handleAddToRecents,
handleUnzipFile,
handleMakeCopy,
onNewFilesSelect,
handleGoogleDriveSelect,
recentFiles,
isFileSupported,
modalHeight,
refreshRecentFiles,
],
);
const deletePromptIsServerOnly = Boolean(deletePromptFile?.remoteStorageId) && deletePromptFile?.id?.startsWith("server-");
const deletePromptIsShared = deletePromptFile?.remoteOwnedByCurrentUser === false;
const deletePromptCanLeaveShare =
deletePromptIsShared && !deletePromptFile?.remoteSharedViaLink && Boolean(deletePromptFile?.remoteStorageId);
return (
<FileManagerContext.Provider value={contextValue}>
{children}
<Modal
opened={Boolean(deletePromptFile)}
onClose={() => resolveDeleteChoice("cancel")}
centered
title={t("fileManager.removeFileTitle", "Remove file")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="sm">
<Text size="sm">
{deletePromptIsServerOnly
? deletePromptIsShared
? deletePromptCanLeaveShare
? t(
"fileManager.removeSharedServerOnlyPrompt",
"This file is shared with you and stored only on the server. Remove it from your list?",
)
: t(
"fileManager.removeSharedServerOnlyBlockedPrompt",
"This file is shared with you and stored only on the server.",
)
: t(
"fileManager.removeServerOnlyPrompt",
"This file is stored only on your server. Would you like to remove it from the server?",
)
: deletePromptIsShared
? t(
"fileManager.removeSharedPrompt",
"This file is shared with you. You can remove it from this device or your shared list.",
)
: t(
"fileManager.removeFilePrompt",
"This file is saved on this device and on your server. Where would you like to remove it from?",
)}
</Text>
<Text size="sm" c="dimmed">
{deletePromptFile?.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => resolveDeleteChoice("cancel")}>
{t("cancel", "Cancel")}
</Button>
{!deletePromptIsServerOnly && (
<Button variant="light" onClick={() => resolveDeleteChoice("local")}>
{t("fileManager.removeLocalOnly", "This device only")}
</Button>
)}
{deletePromptCanLeaveShare && (
<Button variant="light" onClick={() => resolveDeleteChoice("leave")}>
{t("fileManager.leaveShare", "Remove from my list")}
</Button>
)}
{deletePromptFile?.remoteOwnedByCurrentUser !== false && (
<>
<Button variant="light" onClick={() => resolveDeleteChoice("server")}>
{t("fileManager.removeServerOnly", "Server only")}
</Button>
{!deletePromptIsServerOnly && (
<Button color="red" onClick={() => resolveDeleteChoice("both")}>
{t("fileManager.removeBoth", "Remove from both")}
</Button>
)}
</>
)}
</Group>
</Stack>
</Modal>
</FileManagerContext.Provider>
);
};
// Custom hook to use the context
export const useFileManagerContext = (): FileManagerContextValue => {
const context = useContext(FileManagerContext);
if (!context) {
throw new Error(
"useFileManagerContext must be used within a FileManagerProvider. " +
"Make sure you wrap your component with <FileManagerProvider>.",
);
}
return context;
};
// Export the context for advanced use cases
export { FileManagerContext };