From 0806fd7965a40d9a3c39c960eb1c0712b2f38f87 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 5 Jan 2026 23:48:32 +0700 Subject: [PATCH] feat(file-explorer): add download progress toast and auto-restart dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add download status toast (downloading/complete/error) for file downloads - Update to use expo-file-system's new File API - Auto-restart dev server on crash (non-zero exit codes) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/app/src/app/file-explorer.tsx | 134 ++++++++++++++++++++++--- packages/server/scripts/dev-runner.ts | 11 +- 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index 9863638af..672fa9753 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -22,7 +22,7 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; import * as Clipboard from "expo-clipboard"; -import * as FileSystem from "expo-file-system"; +import { File as FSFile, Paths } from "expo-file-system"; import * as Sharing from "expo-sharing"; import { BottomSheetModal, @@ -31,6 +31,8 @@ import { BottomSheetView, } from "@gorhom/bottom-sheet"; import { + Check, + Download, File, FileText, Folder, @@ -39,6 +41,7 @@ import { List as ListIcon, MoreVertical, X, + XCircle, } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; import type { ExplorerEntry } from "@/stores/session-store"; @@ -211,6 +214,12 @@ function FileExplorerContent({ const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); const [menuHeight, setMenuHeight] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); + const [downloadToast, setDownloadToast] = useState<{ + status: "downloading" | "complete" | "error"; + fileName: string; + message?: string; + } | null>(null); + const downloadToastTimeoutRef = useRef | null>(null); const agentIdRef = useRef(agentId); const viewModeRef = useRef(viewMode); const requestFilePreviewRef = useRef(requestFilePreview); @@ -498,12 +507,30 @@ function FileExplorerContent({ setMenuHeight((current) => (current === height ? current : height)); }, []); + const showDownloadToast = useCallback( + (toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => { + if (downloadToastTimeoutRef.current) { + clearTimeout(downloadToastTimeoutRef.current); + downloadToastTimeoutRef.current = null; + } + setDownloadToast(toast); + if (toast.status !== "downloading") { + downloadToastTimeoutRef.current = setTimeout(() => { + setDownloadToast(null); + }, 3000); + } + }, + [] + ); + const handleDownloadEntry = useCallback( async (entry: ExplorerEntry) => { if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { return; } + const displayName = entry.name; + try { const tokenResponse = await requestFileDownloadToken(agentId, entry.path); if (tokenResponse.error || !tokenResponse.token) { @@ -527,21 +554,24 @@ function FileExplorerContent({ return; } - const targetUri = await resolveDownloadTargetUri(fileName); - const downloadResult = await FileSystem.downloadAsync( + showDownloadToast({ status: "downloading", fileName: displayName }); + + const targetFile = resolveDownloadTargetFile(fileName); + const downloadedFile = await FSFile.downloadFileAsync( downloadUrl, - targetUri, + targetFile, downloadTarget.authHeader ? { headers: { Authorization: downloadTarget.authHeader } } : undefined ); + + showDownloadToast({ status: "complete", fileName: displayName }); + if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(downloadResult.uri, { + await Sharing.shareAsync(downloadedFile.uri, { mimeType: tokenResponse.mimeType ?? undefined, dialogTitle: fileName ? `Share ${fileName}` : "Share file", }); - } else { - Alert.alert("Download complete", `Saved to ${downloadResult.uri}`); } } catch (error) { const message = @@ -550,10 +580,10 @@ function FileExplorerContent({ console.warn("[FileExplorer] Download failed:", message); return; } - Alert.alert("Download failed", message); + showDownloadToast({ status: "error", fileName: displayName, message }); } }, - [agentId, daemonProfile, requestFileDownloadToken] + [agentId, daemonProfile, requestFileDownloadToken, showDownloadToast] ); const menuPosition = useMemo(() => { @@ -1011,6 +1041,41 @@ function FileExplorerContent({ )} + + {downloadToast && ( + + + {downloadToast.status === "downloading" ? ( + + ) : downloadToast.status === "complete" ? ( + + ) : ( + + )} + + + {downloadToast.fileName} + + + {downloadToast.status === "downloading" + ? "Downloading..." + : downloadToast.status === "complete" + ? "Download complete" + : downloadToast.message ?? "Download failed"} + + + {downloadToast.status !== "downloading" && ( + setDownloadToast(null)} + hitSlop={8} + style={styles.downloadToastDismiss} + > + + + )} + + + )} ); } @@ -1336,23 +1401,23 @@ function triggerBrowserDownload(url: string, fileName: string) { link.remove(); } -async function resolveDownloadTargetUri(fileName: string): Promise { - const directory = FileSystem.Paths.cache?.uri ?? FileSystem.Paths.document?.uri; +function resolveDownloadTargetFile(fileName: string): FSFile { + const directory = Paths.cache ?? Paths.document; if (!directory) { throw new Error("No download directory available."); } const safeName = sanitizeDownloadFileName(fileName); const split = splitFileName(safeName); - let targetUri = `${directory}${safeName}`; + let targetFile = new FSFile(directory, safeName); let suffix = 1; - while ((await FileSystem.getInfoAsync(targetUri)).exists) { - targetUri = `${directory}${split.base} (${suffix})${split.ext}`; + while (targetFile.exists) { + targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); suffix += 1; } - return targetUri; + return targetFile; } function sanitizeDownloadFileName(fileName: string): string { @@ -1695,4 +1760,43 @@ const styles = StyleSheet.create((theme) => ({ width: "100%", height: "100%", }, + downloadToast: { + position: "absolute", + bottom: theme.spacing[4], + left: theme.spacing[4], + right: theme.spacing[4], + zIndex: 1000, + }, + downloadToastContent: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, + }, + downloadToastTextContainer: { + flex: 1, + gap: theme.spacing[1], + }, + downloadToastFileName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + downloadToastStatus: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + downloadToastDismiss: { + padding: theme.spacing[1], + }, })); diff --git a/packages/server/scripts/dev-runner.ts b/packages/server/scripts/dev-runner.ts index 00cd4c234..b2a4f1464 100644 --- a/packages/server/scripts/dev-runner.ts +++ b/packages/server/scripts/dev-runner.ts @@ -23,16 +23,19 @@ function spawnServer() { }); child.on("exit", (code, signal) => { - if (restarting) { + const exitDescriptor = + signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); + + // Restart on: explicit restart request, or any non-zero exit (crash) + if (restarting || (code !== 0 && code !== null)) { restarting = false; + console.warn(`[DevRunner] Server exited (${exitDescriptor}). Restarting...`); spawnServer(); return; } - const exitDescriptor = - signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); console.warn(`[DevRunner] Server exited (${exitDescriptor}). Shutting down.`); - process.exit(typeof code === "number" ? code : 0); + process.exit(0); }); }