feat(file-explorer): add download progress toast and auto-restart dev server
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
33a3031f74
commit
0806fd7965
@@ -22,7 +22,7 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
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 * as Sharing from "expo-sharing";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
List as ListIcon,
|
List as ListIcon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
X,
|
X,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import type { ExplorerEntry } from "@/stores/session-store";
|
import type { ExplorerEntry } from "@/stores/session-store";
|
||||||
@@ -211,6 +214,12 @@ function FileExplorerContent({
|
|||||||
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
|
||||||
const [menuHeight, setMenuHeight] = useState(0);
|
const [menuHeight, setMenuHeight] = useState(0);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [downloadToast, setDownloadToast] = useState<{
|
||||||
|
status: "downloading" | "complete" | "error";
|
||||||
|
fileName: string;
|
||||||
|
message?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
const viewModeRef = useRef(viewMode);
|
const viewModeRef = useRef(viewMode);
|
||||||
const requestFilePreviewRef = useRef(requestFilePreview);
|
const requestFilePreviewRef = useRef(requestFilePreview);
|
||||||
@@ -498,12 +507,30 @@ function FileExplorerContent({
|
|||||||
setMenuHeight((current) => (current === height ? current : height));
|
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(
|
const handleDownloadEntry = useCallback(
|
||||||
async (entry: ExplorerEntry) => {
|
async (entry: ExplorerEntry) => {
|
||||||
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = entry.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
|
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
|
||||||
if (tokenResponse.error || !tokenResponse.token) {
|
if (tokenResponse.error || !tokenResponse.token) {
|
||||||
@@ -527,21 +554,24 @@ function FileExplorerContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUri = await resolveDownloadTargetUri(fileName);
|
showDownloadToast({ status: "downloading", fileName: displayName });
|
||||||
const downloadResult = await FileSystem.downloadAsync(
|
|
||||||
|
const targetFile = resolveDownloadTargetFile(fileName);
|
||||||
|
const downloadedFile = await FSFile.downloadFileAsync(
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
targetUri,
|
targetFile,
|
||||||
downloadTarget.authHeader
|
downloadTarget.authHeader
|
||||||
? { headers: { Authorization: downloadTarget.authHeader } }
|
? { headers: { Authorization: downloadTarget.authHeader } }
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
showDownloadToast({ status: "complete", fileName: displayName });
|
||||||
|
|
||||||
if (await Sharing.isAvailableAsync()) {
|
if (await Sharing.isAvailableAsync()) {
|
||||||
await Sharing.shareAsync(downloadResult.uri, {
|
await Sharing.shareAsync(downloadedFile.uri, {
|
||||||
mimeType: tokenResponse.mimeType ?? undefined,
|
mimeType: tokenResponse.mimeType ?? undefined,
|
||||||
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
|
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
Alert.alert("Download complete", `Saved to ${downloadResult.uri}`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -550,10 +580,10 @@ function FileExplorerContent({
|
|||||||
console.warn("[FileExplorer] Download failed:", message);
|
console.warn("[FileExplorer] Download failed:", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert("Download failed", message);
|
showDownloadToast({ status: "error", fileName: displayName, message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentId, daemonProfile, requestFileDownloadToken]
|
[agentId, daemonProfile, requestFileDownloadToken, showDownloadToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const menuPosition = useMemo(() => {
|
const menuPosition = useMemo(() => {
|
||||||
@@ -1011,6 +1041,41 @@ function FileExplorerContent({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|
||||||
|
{downloadToast && (
|
||||||
|
<View style={styles.downloadToast}>
|
||||||
|
<View style={styles.downloadToastContent}>
|
||||||
|
{downloadToast.status === "downloading" ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
|
) : downloadToast.status === "complete" ? (
|
||||||
|
<Check size={18} color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<XCircle size={18} color={theme.colors.destructive} />
|
||||||
|
)}
|
||||||
|
<View style={styles.downloadToastTextContainer}>
|
||||||
|
<Text style={styles.downloadToastFileName} numberOfLines={1}>
|
||||||
|
{downloadToast.fileName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.downloadToastStatus}>
|
||||||
|
{downloadToast.status === "downloading"
|
||||||
|
? "Downloading..."
|
||||||
|
: downloadToast.status === "complete"
|
||||||
|
? "Download complete"
|
||||||
|
: downloadToast.message ?? "Download failed"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{downloadToast.status !== "downloading" && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setDownloadToast(null)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.downloadToastDismiss}
|
||||||
|
>
|
||||||
|
<X size={16} color={theme.colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1336,23 +1401,23 @@ function triggerBrowserDownload(url: string, fileName: string) {
|
|||||||
link.remove();
|
link.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDownloadTargetUri(fileName: string): Promise<string> {
|
function resolveDownloadTargetFile(fileName: string): FSFile {
|
||||||
const directory = FileSystem.Paths.cache?.uri ?? FileSystem.Paths.document?.uri;
|
const directory = Paths.cache ?? Paths.document;
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
throw new Error("No download directory available.");
|
throw new Error("No download directory available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = sanitizeDownloadFileName(fileName);
|
const safeName = sanitizeDownloadFileName(fileName);
|
||||||
const split = splitFileName(safeName);
|
const split = splitFileName(safeName);
|
||||||
let targetUri = `${directory}${safeName}`;
|
let targetFile = new FSFile(directory, safeName);
|
||||||
let suffix = 1;
|
let suffix = 1;
|
||||||
|
|
||||||
while ((await FileSystem.getInfoAsync(targetUri)).exists) {
|
while (targetFile.exists) {
|
||||||
targetUri = `${directory}${split.base} (${suffix})${split.ext}`;
|
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return targetUri;
|
return targetFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeDownloadFileName(fileName: string): string {
|
function sanitizeDownloadFileName(fileName: string): string {
|
||||||
@@ -1695,4 +1760,43 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "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],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ function spawnServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
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;
|
restarting = false;
|
||||||
|
console.warn(`[DevRunner] Server exited (${exitDescriptor}). Restarting...`);
|
||||||
spawnServer();
|
spawnServer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitDescriptor =
|
|
||||||
signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
|
|
||||||
console.warn(`[DevRunner] Server exited (${exitDescriptor}). Shutting down.`);
|
console.warn(`[DevRunner] Server exited (${exitDescriptor}). Shutting down.`);
|
||||||
process.exit(typeof code === "number" ? code : 0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user