mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Various bug fixes found while testing SaaS build (#6459)
# Description of Changes Various fixes and improvements I made while testing the SaaS code: - Changes the new `.env.saas` file to live in `app/` and match the semantics of the other `.env` files - Adds top-level `task dev:saas` command to spawn SaaS frontend & backend - Deletes dead SaaS code and improves some overriding logic - Fixes refreshing issue when coming back to the tab - Fix the Compare tool's selection logic - Make Compare handle error cases properly - Fixes the location of the "Dismiss All Errors" button (was rendering on top of the top-bar with a transparent background previously so it looked rubbish) - Fixes file selection in PDF Editor
This commit is contained in:
@@ -39,7 +39,7 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "1rem",
|
||||
top: "calc(48px + 1rem)", // Sit below the 48px-tall WorkbenchBar (top toolbar)
|
||||
right: "1rem",
|
||||
zIndex: Z_INDEX_TOAST,
|
||||
pointerEvents: "auto",
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
@@ -143,9 +146,10 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes
|
||||
const indexedDBRevision = useIndexedDBRevision();
|
||||
useEffect(() => {
|
||||
refreshStubs();
|
||||
}, [refreshStubs, indexedDB.revision]);
|
||||
}, [refreshStubs, indexedDBRevision]);
|
||||
|
||||
// Once a pending file lands in state, open it in the viewer.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,7 +18,6 @@ export const VALID_NAV_KEYS = [
|
||||
"adminGeneral",
|
||||
"adminSecurity",
|
||||
"adminConnections",
|
||||
"adminTelegram",
|
||||
"adminPrivacy",
|
||||
"adminDatabase",
|
||||
"adminAdvanced",
|
||||
|
||||
@@ -185,29 +185,34 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
||||
const comparisonTitle =
|
||||
comparisonStub?.name || result?.comparison?.fileName || "";
|
||||
|
||||
// During diff processing, show compact spinners in the dropdown badges
|
||||
const baseDropdownPlaceholder =
|
||||
isOperationLoading || !result ? (
|
||||
<span className="inline-flex flex-row items-center gap-1">
|
||||
{t("compare.dropdown.deletionsLabel", "Deletions")}{" "}
|
||||
<Loader size="xs" color="currentColor" />
|
||||
</span>
|
||||
) : (
|
||||
t("compare.dropdown.deletions", "Deletions ({{count}})", {
|
||||
count: baseWordChanges.length,
|
||||
})
|
||||
);
|
||||
const comparisonDropdownPlaceholder =
|
||||
isOperationLoading || !result ? (
|
||||
<span className="inline-flex flex-row items-center gap-1">
|
||||
{t("compare.dropdown.additionsLabel", "Additions")}{" "}
|
||||
<Loader size="xs" color="currentColor" />
|
||||
</span>
|
||||
) : (
|
||||
t("compare.dropdown.additions", "Additions ({{count}})", {
|
||||
count: comparisonWordChanges.length,
|
||||
})
|
||||
);
|
||||
// During diff processing, show compact spinners in the dropdown badges.
|
||||
// If the operation finished without a result (e.g. errored — the toast
|
||||
// already told the user why), show the bare label so the dropdowns don't
|
||||
// spin forever waiting for a result that will never arrive.
|
||||
const baseDropdownPlaceholder = isOperationLoading ? (
|
||||
<span className="inline-flex flex-row items-center gap-1">
|
||||
{t("compare.dropdown.deletionsLabel", "Deletions")}{" "}
|
||||
<Loader size="xs" color="currentColor" />
|
||||
</span>
|
||||
) : result ? (
|
||||
t("compare.dropdown.deletions", "Deletions ({{count}})", {
|
||||
count: baseWordChanges.length,
|
||||
})
|
||||
) : (
|
||||
t("compare.dropdown.deletionsLabel", "Deletions")
|
||||
);
|
||||
const comparisonDropdownPlaceholder = isOperationLoading ? (
|
||||
<span className="inline-flex flex-row items-center gap-1">
|
||||
{t("compare.dropdown.additionsLabel", "Additions")}{" "}
|
||||
<Loader size="xs" color="currentColor" />
|
||||
</span>
|
||||
) : result ? (
|
||||
t("compare.dropdown.additions", "Additions ({{count}})", {
|
||||
count: comparisonWordChanges.length,
|
||||
})
|
||||
) : (
|
||||
t("compare.dropdown.additionsLabel", "Additions")
|
||||
);
|
||||
|
||||
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
||||
layout,
|
||||
|
||||
@@ -18,7 +18,10 @@ import { fileStorage } from "@app/services/fileStorage";
|
||||
import { folderSyncService } from "@app/services/folderSyncService";
|
||||
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
||||
import { reconcileServerFiles } from "@app/services/fileSyncService";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -134,6 +137,7 @@ const FilesPageContext = createContext<FilesPageContextValue | null>(null);
|
||||
export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const indexedDB = useIndexedDB();
|
||||
const indexedDBRevision = useIndexedDBRevision();
|
||||
const folders = useFolders();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
@@ -182,7 +186,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, indexedDB.revision]);
|
||||
}, [refresh, indexedDBRevision]);
|
||||
|
||||
const fileMap = useMemo(() => {
|
||||
const map = new Map<FileId, StirlingFileStub>();
|
||||
|
||||
@@ -59,13 +59,19 @@ interface IndexedDBContextValue {
|
||||
) => Promise<FileId[]>;
|
||||
clearFolderForFiles: (folderIds: FolderId[]) => Promise<number>;
|
||||
|
||||
// Incremented after any write or delete - subscribe to trigger re-reads
|
||||
revision: number;
|
||||
// Bumped after any write or delete. Subscribe to changes via
|
||||
// `useIndexedDBRevision()` - exposing the value here would force the
|
||||
// entire API context to invalidate on every write, cascading downstream
|
||||
// useCallback deps and creating refetch loops.
|
||||
bumpRevision: () => void;
|
||||
}
|
||||
|
||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||
|
||||
// Separate context for the revision number so consumers that only need the
|
||||
// stable method API are not re-rendered when revision bumps.
|
||||
const IndexedDBRevisionContext = createContext<number>(0);
|
||||
|
||||
interface IndexedDBProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -271,8 +277,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
[bumpRevision],
|
||||
);
|
||||
|
||||
// Memoise so identity-based context propagation doesn't re-render every
|
||||
// downstream consumer on each parent render.
|
||||
// Memoize the context value so consumers' useIndexedDB() reference stays
|
||||
// stable across renders. Without this, every IndexedDBProvider render
|
||||
// (e.g. on bumpRevision after a thumbnail write) hands every consumer a
|
||||
// brand-new object, invalidating downstream useCallback deps that include
|
||||
// `indexedDB` (notably useFileManager.loadRecentFiles), which can cascade
|
||||
// into infinite refetch loops in components that depend on those callbacks.
|
||||
// `revision` is intentionally NOT a dep here - consume it via
|
||||
// `useIndexedDBRevision()` so revision bumps don't churn the API value.
|
||||
const value = useMemo<IndexedDBContextValue>(
|
||||
() => ({
|
||||
saveFile,
|
||||
@@ -288,7 +300,6 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
markFileAsProcessed,
|
||||
moveFilesToFolder,
|
||||
clearFolderForFiles,
|
||||
revision,
|
||||
bumpRevision,
|
||||
}),
|
||||
[
|
||||
@@ -305,14 +316,15 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
markFileAsProcessed,
|
||||
moveFilesToFolder,
|
||||
clearFolderForFiles,
|
||||
revision,
|
||||
bumpRevision,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<IndexedDBContext.Provider value={value}>
|
||||
{children}
|
||||
<IndexedDBRevisionContext.Provider value={revision}>
|
||||
{children}
|
||||
</IndexedDBRevisionContext.Provider>
|
||||
</IndexedDBContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -324,3 +336,11 @@ export function useIndexedDB() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to IndexedDB write revisions. The number increments after any
|
||||
* write or delete; use it as a `useEffect` dep to refetch stubs etc.
|
||||
*/
|
||||
export function useIndexedDBRevision(): number {
|
||||
return useContext(IndexedDBRevisionContext);
|
||||
}
|
||||
|
||||
@@ -609,24 +609,34 @@ export const useCompareOperation = (): CompareOperationHook => {
|
||||
} catch (error: unknown) {
|
||||
console.error("[compare] operation failed", error);
|
||||
const errorCode = getWorkerErrorCode(error);
|
||||
let resolvedMessage: string;
|
||||
if (errorCode === "EMPTY_TEXT") {
|
||||
setErrorMessage(
|
||||
resolvedMessage =
|
||||
warningMessages.emptyTextMessage ??
|
||||
t("compare.error.generic", "Unable to compare these files."),
|
||||
);
|
||||
t("compare.error.generic", "Unable to compare these files.");
|
||||
} else {
|
||||
const fallbackMessage = t(
|
||||
"compare.error.generic",
|
||||
"Unable to compare these files.",
|
||||
);
|
||||
if (error instanceof Error && error.message) {
|
||||
setErrorMessage(error.message);
|
||||
resolvedMessage = error.message;
|
||||
} else if (typeof error === "string" && error.trim().length > 0) {
|
||||
setErrorMessage(error);
|
||||
resolvedMessage = error;
|
||||
} else {
|
||||
setErrorMessage(fallbackMessage);
|
||||
resolvedMessage = fallbackMessage;
|
||||
}
|
||||
}
|
||||
setErrorMessage(resolvedMessage);
|
||||
setStatusState("error");
|
||||
// Surface the failure to the user. Without this, the error only goes
|
||||
// to the console and the custom workbench just keeps spinning.
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("compare.error.title", "Comparison failed"),
|
||||
body: resolvedMessage,
|
||||
location: "bottom-right" as ToastLocation,
|
||||
});
|
||||
} finally {
|
||||
const duration = performance.now() - operationStart;
|
||||
setStatusDetailMs(Math.round(duration));
|
||||
@@ -696,7 +706,9 @@ export const useCompareOperation = (): CompareOperationHook => {
|
||||
? t("compare.status.complete", "Comparison ready")
|
||||
: statusState === "cancelled"
|
||||
? t("operationCancelled", "Operation cancelled")
|
||||
: "";
|
||||
: statusState === "error"
|
||||
? t("compare.status.error", "Comparison failed")
|
||||
: "";
|
||||
if (label && statusDetailMs != null)
|
||||
return `${label} (${statusDetailMs} ms)`;
|
||||
return label;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync";
|
||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||
|
||||
// Track whether we've done the global fetch to prevent duplicate requests
|
||||
let globalFetchDone = false;
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
function resetGlobalCache() {
|
||||
globalFetchDone = false;
|
||||
Object.keys(globalEndpointCache).forEach(
|
||||
(key) => delete globalEndpointCache[key],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
* This wraps the context for single endpoint checks
|
||||
@@ -78,89 +87,116 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAllEndpointStatuses = async (force = false) => {
|
||||
// Skip if already fetched globally and not forced
|
||||
if (!force && globalFetchDone) {
|
||||
console.debug("[useEndpointConfig] Using global cache");
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug(
|
||||
"[useEndpointConfig] Fetching all endpoint statuses from server",
|
||||
);
|
||||
|
||||
// Fetch all endpoints at once - no query params needed
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`);
|
||||
|
||||
// Populate global cache with all results
|
||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
globalFetchDone = true;
|
||||
|
||||
// Return status for the requested endpoints
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details }));
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn(
|
||||
"[useEndpointConfig] 401 error - using optimistic fallback",
|
||||
const fetchAllEndpointStatuses = useCallback(
|
||||
async (force = false) => {
|
||||
// Skip if already fetched globally and not forced
|
||||
if (!force && globalFetchDone) {
|
||||
console.debug("[useEndpointConfig] Using global cache");
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
endpoints.forEach((endpoint) => {
|
||||
globalEndpointCache[endpoint] = { enabled: true, reason: null };
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug(
|
||||
"[useEndpointConfig] Fetching all endpoint statuses from server",
|
||||
);
|
||||
|
||||
// Fetch all endpoints at once - no query params needed
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`);
|
||||
|
||||
// Populate global cache with all results
|
||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
globalFetchDone = true;
|
||||
|
||||
// Return status for the requested endpoints
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details }));
|
||||
} catch (err: unknown) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (isAxiosError(err) && err.response?.status === 401) {
|
||||
console.warn(
|
||||
"[useEndpointConfig] 401 error - using optimistic fallback",
|
||||
);
|
||||
endpoints.forEach((endpoint) => {
|
||||
globalEndpointCache[endpoint] = { enabled: true, reason: null };
|
||||
});
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = { enabled: true, reason: null };
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails((prev) => ({
|
||||
...prev,
|
||||
...optimisticStatus.details,
|
||||
}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
console.error("[EndpointConfig] Failed to check endpoints:", err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
acc.status[endpoint] = true;
|
||||
@@ -177,55 +213,29 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
...prev,
|
||||
...optimisticStatus.details,
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
console.error("[EndpointConfig] Failed to check endpoints:", err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = { enabled: true, reason: null };
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
status: {} as Record<string, boolean>,
|
||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||
},
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[endpoints.join(",")],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllEndpointStatuses();
|
||||
}, [endpoints.join(",")]); // Re-run when endpoints array changes
|
||||
}, [fetchAllEndpointStatuses]);
|
||||
|
||||
// Listen for JWT availability (triggered on login/signup)
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug(
|
||||
"[useEndpointConfig] JWT available event - clearing cache for refetch with auth",
|
||||
);
|
||||
globalFetchDone = false;
|
||||
Object.keys(globalEndpointCache).forEach(
|
||||
(key) => delete globalEndpointCache[key],
|
||||
);
|
||||
fetchAllEndpointStatuses(true);
|
||||
};
|
||||
|
||||
window.addEventListener("jwt-available", handleJwtAvailable);
|
||||
return () =>
|
||||
window.removeEventListener("jwt-available", handleJwtAvailable);
|
||||
}, [endpoints.join(",")]);
|
||||
// Re-fetch when auth state changes. Core implementation listens for the
|
||||
// proprietary `jwt-available` event; the SaaS no-op override means the
|
||||
// cache simply isn't invalidated on Supabase auth changes (today's behavior).
|
||||
// If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts.
|
||||
const handleAuthChange = useCallback(() => {
|
||||
console.debug(
|
||||
"[useEndpointConfig] Auth changed - clearing cache for refetch",
|
||||
);
|
||||
resetGlobalCache();
|
||||
fetchAllEndpointStatuses(true);
|
||||
}, [fetchAllEndpointStatuses]);
|
||||
useJwtConfigSync(handleAuthChange);
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
|
||||
@@ -230,11 +230,28 @@ const Compare = (props: BaseToolProps) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Operation finished without producing a fresh result (e.g. it errored).
|
||||
// Without this branch, `prepareWorkbenchForRun` leaves `isLoading: true`
|
||||
// and the workbench spins forever.
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
if (previous?.isLoading) {
|
||||
updateWorkbenchData({
|
||||
...previous,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile: baseSlot?.stirlingFile ?? previous.baseLocalFile ?? null,
|
||||
comparisonLocalFile:
|
||||
compSlot?.stirlingFile ?? previous.comparisonLocalFile ?? null,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
base.operation.isLoading,
|
||||
baseSlot,
|
||||
clearCustomWorkbenchViewData,
|
||||
compSlot,
|
||||
operation.errorMessage,
|
||||
operation.result,
|
||||
params.baseFileId,
|
||||
params.comparisonFileId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useAllFiles,
|
||||
useFileSelection,
|
||||
useFileManagement,
|
||||
useFileContext,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { getDefaultWorkbench } from "@app/types/workbench";
|
||||
@@ -382,6 +384,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
[t],
|
||||
);
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
const { activeFileId } = useViewer();
|
||||
|
||||
// The file the tool should auto-load: prefer the sidebar selection, then
|
||||
// whatever the viewer is currently showing (so opening PDF Editor from the
|
||||
// viewer picks up that file), then the single workbench file if there is
|
||||
// only one. Returns null if the choice is ambiguous (no selection, no
|
||||
// viewer file, and multiple files in the workbench).
|
||||
const autoLoadFile = useMemo(() => {
|
||||
if (selectedFiles[0]) return selectedFiles[0];
|
||||
if (activeFileId) {
|
||||
const viewerFile = allFiles.find(
|
||||
(f) => (f.fileId as string) === activeFileId,
|
||||
);
|
||||
if (viewerFile) return viewerFile;
|
||||
}
|
||||
if (allFiles.length === 1) return allFiles[0];
|
||||
return null;
|
||||
}, [selectedFiles, activeFileId, allFiles]);
|
||||
|
||||
const resetToDocument = useCallback(
|
||||
(
|
||||
@@ -1917,7 +1938,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length === 0) {
|
||||
if (!autoLoadFile) {
|
||||
autoLoadKeyRef.current = null;
|
||||
sourceFileIdRef.current = null;
|
||||
return;
|
||||
@@ -1927,21 +1948,16 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = selectedFiles[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileKey = getAutoLoadKey(file);
|
||||
const fileKey = getAutoLoadKey(autoLoadFile);
|
||||
if (autoLoadKeyRef.current === fileKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoLoadKeyRef.current = fileKey;
|
||||
// Capture the source file ID for save-to-workbench functionality
|
||||
sourceFileIdRef.current = (file as any).fileId ?? null;
|
||||
void handleLoadFile(file);
|
||||
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]);
|
||||
sourceFileIdRef.current = (autoLoadFile as any).fileId ?? null;
|
||||
void handleLoadFile(autoLoadFile);
|
||||
}, [autoLoadFile, navigationState.selectedTool, handleLoadFile]);
|
||||
|
||||
// Auto-navigate to workbench when tool is selected
|
||||
const hasAutoOpenedWorkbenchRef = useRef(false);
|
||||
|
||||
Reference in New Issue
Block a user