mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca52e0b352 |
@@ -3608,6 +3608,8 @@ lastSynced = "Last synced"
|
||||
leaveShare = "Remove from my list"
|
||||
leaveShareFailed = "Could not remove the shared file."
|
||||
leaveShareSuccess = "Removed from your shared list."
|
||||
loadFailed = "Couldn't load your recent files"
|
||||
loadFailedRetry = "Try again"
|
||||
loadingFiles = "Loading files..."
|
||||
localOnly = "Local only"
|
||||
makeCopy = "Make a copy"
|
||||
|
||||
@@ -42,7 +42,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading, loadError } =
|
||||
useFileManager();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Get active file IDs from FileContext to show which files are already loaded
|
||||
@@ -240,6 +241,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
loadError={loadError}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mock i18n so assertions can rely on the English default strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Isolate FileListArea's branching logic from its heavy children — we only
|
||||
// care which state it renders, not how each child looks.
|
||||
vi.mock("@app/components/fileManager/EmptyFilesState", () => ({
|
||||
default: () => <div>No recent files</div>,
|
||||
}));
|
||||
vi.mock("@app/components/fileManager/FileListItem", () => ({
|
||||
default: ({ file }: { file: { name: string } }) => <div>{file.name}</div>,
|
||||
}));
|
||||
vi.mock("@app/components/fileManager/FileHistoryGroup", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@app/ui/Button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => <button onClick={onClick}>{children}</button>,
|
||||
}));
|
||||
|
||||
const contextValue = vi.hoisted(() => ({
|
||||
current: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock("@app/contexts/FileManagerContext", () => ({
|
||||
useFileManagerContext: () => contextValue.current,
|
||||
}));
|
||||
|
||||
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||
|
||||
function baseContext(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
activeSource: "recent",
|
||||
recentFiles: [],
|
||||
filteredFiles: [],
|
||||
selectedFilesSet: new Set(),
|
||||
expandedFileIds: new Set(),
|
||||
loadedHistoryFiles: new Map(),
|
||||
onFileSelect: vi.fn(),
|
||||
onFileRemove: vi.fn(),
|
||||
onHistoryFileRemove: vi.fn(),
|
||||
onFileDoubleClick: vi.fn(),
|
||||
onDownloadSingle: vi.fn(),
|
||||
isLoading: false,
|
||||
loadError: false,
|
||||
activeFileIds: [],
|
||||
refreshRecentFiles: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FileListArea recent-files states", () => {
|
||||
beforeEach(() => {
|
||||
contextValue.current = baseContext({});
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no files and no error", () => {
|
||||
contextValue.current = baseContext({ isLoading: false, loadError: false });
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("No recent files")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Try again")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loading state while loading with no files yet", () => {
|
||||
contextValue.current = baseContext({ isLoading: true });
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("Loading files...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error state with a retry action when the load fails (not the empty state)", () => {
|
||||
const refreshRecentFiles = vi.fn();
|
||||
contextValue.current = baseContext({
|
||||
isLoading: false,
|
||||
loadError: true,
|
||||
refreshRecentFiles,
|
||||
});
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The failed load must be visually distinct from a genuinely empty history.
|
||||
expect(
|
||||
screen.getByText("Couldn't load your recent files"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
|
||||
const retry = screen.getByText("Try again");
|
||||
fireEvent.click(retry);
|
||||
expect(refreshRecentFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the file list when files are present even if a partial load error occurred", () => {
|
||||
contextValue.current = baseContext({
|
||||
recentFiles: [{ id: "a" }],
|
||||
filteredFiles: [{ id: "a", name: "doc.pdf" }],
|
||||
loadError: true,
|
||||
});
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("doc.pdf")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Couldn't load your recent files"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
|
||||
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -29,7 +31,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
refreshRecentFiles,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,15 +48,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
{recentFiles.length > 0 ? (
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
@@ -84,6 +80,39 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : isLoading ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : loadError ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<ErrorOutlineIcon
|
||||
style={{
|
||||
fontSize: "3rem",
|
||||
color: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t(
|
||||
"fileManager.loadFailed",
|
||||
"Couldn't load your recent files",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void refreshRecentFiles();
|
||||
}}
|
||||
>
|
||||
{t("fileManager.loadFailedRetry", "Try again")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<EmptyFilesState />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -42,6 +42,7 @@ interface FileManagerContextValue {
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
|
||||
// Handlers
|
||||
@@ -96,6 +97,7 @@ interface FileManagerProviderProps {
|
||||
modalHeight: string;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
maxSelectable?: number | null;
|
||||
}
|
||||
@@ -115,6 +117,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
modalHeight,
|
||||
refreshRecentFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
maxSelectable = null,
|
||||
}) => {
|
||||
@@ -1089,6 +1092,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
|
||||
// Handlers
|
||||
@@ -1131,6 +1135,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
handleSourceChange,
|
||||
handleStorageFilterChange,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { captureException } from "@app/services/analytics";
|
||||
|
||||
interface StoredFileResponse {
|
||||
id: number;
|
||||
@@ -33,6 +34,7 @@ interface AccessedShareLinkResponse {
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
@@ -99,6 +101,11 @@ export const useFileManager = () => {
|
||||
|
||||
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
// Track whether any part of the load failed so callers can distinguish a
|
||||
// failed/partial load from a genuinely empty history instead of silently
|
||||
// falling through to an empty list.
|
||||
let encounteredError = false;
|
||||
try {
|
||||
if (!indexedDB) {
|
||||
return [];
|
||||
@@ -122,6 +129,10 @@ export const useFileManager = () => {
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
// Metadata listing, not a file transfer — bound the wait so a
|
||||
// stalled connection surfaces as an error/retry instead of an
|
||||
// indefinite "Loading files..." spinner.
|
||||
timeout: 30000,
|
||||
} as any,
|
||||
);
|
||||
const serverFiles = Array.isArray(response.data) ? response.data : [];
|
||||
@@ -242,6 +253,8 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...updatedLocalStubs, ...serverStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.serverFiles" });
|
||||
console.warn("Failed to load server files:", error);
|
||||
}
|
||||
|
||||
@@ -252,6 +265,8 @@ export const useFileManager = () => {
|
||||
>("/api/v1/storage/share-links/accessed", {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
// Bound the wait — see server-files fetch above.
|
||||
timeout: 30000,
|
||||
} as any);
|
||||
const sharedLinks = Array.isArray(sharedResponse.data)
|
||||
? sharedResponse.data
|
||||
@@ -346,6 +361,8 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...combinedStubs, ...sharedStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.shareLinks" });
|
||||
console.warn("Failed to load shared links:", error);
|
||||
}
|
||||
}
|
||||
@@ -358,9 +375,12 @@ export const useFileManager = () => {
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles" });
|
||||
console.error("Failed to load recent files:", error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadError(encounteredError);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
@@ -505,6 +525,7 @@ export const useFileManager = () => {
|
||||
|
||||
return {
|
||||
loading,
|
||||
loadError,
|
||||
convertToFile,
|
||||
loadRecentFiles,
|
||||
handleRemoveFile,
|
||||
|
||||
@@ -38,3 +38,21 @@ export function trackEditorOperation(toolId: string, fileCount: number): void {
|
||||
if (DEV) console.warn("[analytics] trackEditorOperation failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught error to PostHog so otherwise-swallowed failures become
|
||||
* visible as `$exception` events instead of vanishing into a console.warn.
|
||||
*/
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
try {
|
||||
if (!canCapture()) return;
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
posthog.captureException(normalized, context);
|
||||
} catch (captureError) {
|
||||
if (DEV) console.warn("[analytics] captureException failed", captureError);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user