fix: resolve file library Open File bug, upload reliability, and SSE proxy timeouts (#203)

The Open File button in the Files section did nothing due to a race
condition where the home page reset the file store on mount before files
from handleOpenFile could render. Upload on the files page used fetch
with no timeout, progress, or retry, causing silent failures on mobile
and slow connections. SSE connections for job progress had no keepalive
pings, allowing reverse proxies to kill idle streams.
This commit is contained in:
SnapOtter
2026-06-05 19:01:40 +08:00
committed by GitHub
parent f1aae73397
commit 01421640b5
9 changed files with 176 additions and 44 deletions
@@ -119,7 +119,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
if (valid.length === 0) return;
setFiles(valid.map((d) => d.file));
navigate("/");
navigate("/", { state: { fromLibrary: true } });
// Set serverFileId on each entry so tool processing creates new versions
setTimeout(() => {
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { useFilesPageStore } from "@/stores/files-page-store";
export function FileUploadArea() {
const { uploadFiles, loading } = useFilesPageStore();
const { uploadFiles, loading, uploadProgress } = useFilesPageStore();
const [dragging, setDragging] = useState(false);
function handleDragOver(e: React.DragEvent) {
@@ -46,7 +46,12 @@ export function FileUploadArea() {
)}
>
{loading ? (
<div className="h-10 w-10 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-2">
<div className="h-10 w-10 border-2 border-primary border-t-transparent rounded-full animate-spin" />
{uploadProgress !== null && uploadProgress > 0 && (
<span className="text-xs font-medium text-primary">{uploadProgress}%</span>
)}
</div>
) : (
<Upload className="h-10 w-10 text-muted-foreground" />
)}
@@ -54,7 +59,9 @@ export function FileUploadArea() {
<p className="text-sm font-medium text-foreground">
{loading ? "Uploading..." : "Drop images here"}
</p>
<p className="text-xs text-muted-foreground mt-1">or click to select files</p>
<p className="text-xs text-muted-foreground mt-1">
{loading ? "" : "or click to select files"}
</p>
</div>
<input
type="file"
+46 -16
View File
@@ -237,26 +237,56 @@ export async function apiGetFileDetails(id: string): Promise<UserFileDetail> {
return { ...res.file, versions: res.versions };
}
export async function apiUploadUserFiles(
export function apiUploadUserFiles(
files: File[],
onProgress?: (percent: number) => void,
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
const formData = new FormData();
for (const f of files) formData.append("files", f);
let res: Response;
try {
res = await fetch("/api/v1/files/upload", {
method: "POST",
headers: formatHeaders(),
body: formData,
return new Promise((resolve, reject) => {
const formData = new FormData();
for (const f of files) formData.append("files", f);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/files/upload");
xhr.timeout = 120_000;
const headers = formatHeaders();
headers.forEach((value, key) => {
if (key.toLowerCase() !== "content-type") {
xhr.setRequestHeader(key, value);
}
});
} catch (error) {
if (error instanceof TypeError) {
useConnectionStore.getState().setDisconnected();
if (onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
}
throw error;
}
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new Error("Invalid server response"));
}
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => {
useConnectionStore.getState().setDisconnected();
reject(new TypeError("Network error"));
};
xhr.ontimeout = () => {
reject(new Error("Upload timed out"));
};
xhr.send(formData);
});
}
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
+8 -3
View File
@@ -1,7 +1,7 @@
import { CATEGORIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import { Clock, Download, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout";
@@ -29,12 +29,17 @@ export function HomePage() {
currentEntry,
} = useFileStore();
const navigate = useNavigate();
const location = useLocation();
const { fetch: fetchSettings, defaultToolView, loaded: settingsLoaded } = useSettingsStore();
const { fetch: fetchFeatures, bundles, installing, queued } = useFeaturesStore();
useEffect(() => {
reset();
}, [reset]);
if (location.state?.fromLibrary) {
navigate(".", { replace: true, state: {} });
} else {
reset();
}
}, [reset, location.state, navigate]);
useEffect(() => {
fetchSettings();
+27 -7
View File
@@ -9,6 +9,7 @@ interface FilesPageState {
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
uploadProgress: number | null;
error: string | null;
fetchFiles: () => Promise<void>;
@@ -29,6 +30,7 @@ export const useFilesPageStore = create<FilesPageState>((set, get) => ({
activeTab: "recent",
searchQuery: "",
loading: false,
uploadProgress: null,
error: null,
fetchFiles: async () => {
@@ -43,14 +45,32 @@ export const useFilesPageStore = create<FilesPageState>((set, get) => ({
},
uploadFiles: async (files) => {
set({ loading: true, error: null });
try {
await apiUploadUserFiles(files);
await get().fetchFiles();
set({ activeTab: "recent" });
} catch (err) {
set({ error: err instanceof Error ? err.message : "Upload failed", loading: false });
set({ loading: true, error: null, uploadProgress: 0 });
let lastError: Error | null = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await apiUploadUserFiles(files, (percent) => {
set({ uploadProgress: percent });
});
set({ uploadProgress: null });
await get().fetchFiles();
set({ activeTab: "recent" });
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error("Upload failed");
if (attempt === 0 && lastError instanceof TypeError) {
set({ uploadProgress: 0 });
await new Promise((r) => setTimeout(r, 1500));
continue;
}
break;
}
}
set({
error: lastError?.message ?? "Upload failed",
loading: false,
uploadProgress: null,
});
},
deleteChecked: async () => {