import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, FlatList, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSocket } from "@/contexts/SocketContext"; import { createFolder, listFolders } from "@/lib/api"; import { colors, monoFont } from "@/lib/theme"; import type { FolderEntry, FolderListResult } from "@/lib/types"; function FolderRow({ item, isLast, onPress, }: { item: FolderEntry; isLast: boolean; onPress: () => void; }) { return ( [styles.row, pressed && styles.rowPressed]} onPress={onPress} > {item.name} ); } function NewFolderModal({ visible, name, creating, error, onChangeName, onCancel, onCreate, }: { visible: boolean; name: string; creating: boolean; error: string | null; onChangeName: (value: string) => void; onCancel: () => void; onCreate: () => void; }) { return ( New folder {error ? {error} : null} [styles.modalButton, pressed && styles.pressedDim]} onPress={onCancel} disabled={creating} > Cancel [ styles.modalButton, styles.modalButtonPrimary, (creating || !name.trim()) && styles.useButtonDisabled, pressed && !creating && styles.pressedDim, ]} onPress={onCreate} disabled={creating || !name.trim()} > {creating ? ( ) : ( Create )} ); } function EmptyFolders() { return ( No folders here This directory has no subfolders to browse. ); } function NoSearchResults({ query }: { query: string }) { return ( No matches No folders match "{query}". ); } export default function FolderPickerScreen() { const { baseUrl, addProject, savedProjects, error } = useSocket(); const insets = useSafeAreaInsets(); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); const [adding, setAdding] = useState(false); const [localError, setLocalError] = useState(null); const [newFolderVisible, setNewFolderVisible] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [creatingFolder, setCreatingFolder] = useState(false); const [newFolderError, setNewFolderError] = useState(null); const [search, setSearch] = useState(""); const loadPath = useCallback( async (path = "~") => { if (!baseUrl) { return; } setLoading(true); setLocalError(null); setSearch(""); try { const result = (await listFolders(baseUrl, path)) as FolderListResult; setCurrent(result); } catch (loadError) { const message = loadError instanceof Error ? loadError.message : "Failed to load folders"; setLocalError(message); } finally { setLoading(false); } }, [baseUrl], ); useEffect(() => { void loadPath("~"); }, [loadPath]); const useFolder = async () => { if (!current) { return; } setAdding(true); setLocalError(null); try { await addProject(current.path); router.replace({ pathname: "/project", params: { cwd: current.path } }); } catch (addError) { const message = addError instanceof Error ? addError.message : "Failed to add project"; setLocalError(message); setAdding(false); } }; const openNewFolder = () => { setNewFolderName(""); setNewFolderError(null); setNewFolderVisible(true); }; const closeNewFolder = () => { if (creatingFolder) { return; } setNewFolderVisible(false); }; const submitNewFolder = async () => { const trimmed = newFolderName.trim(); if (!trimmed || !current || !baseUrl) { return; } setCreatingFolder(true); setNewFolderError(null); try { await createFolder(baseUrl, current.path, trimmed); setNewFolderVisible(false); await loadPath(current.path); } catch (createError) { const message = createError instanceof Error ? createError.message : "Failed to create folder"; setNewFolderError(message); } finally { setCreatingFolder(false); } }; const alreadyAdded = current ? savedProjects.includes(current.path) : false; const entries = current?.entries ?? []; const folderCount = entries.length; const trimmedSearch = search.trim().toLowerCase(); const filteredEntries = trimmedSearch ? entries.filter((item) => item.name.toLowerCase().includes(trimmedSearch)) : entries; if (!baseUrl) { return ( Not connected Connect to a host first. ); } return ( {current?.parent ? ( [styles.upButton, pressed && styles.pressedDim]} onPress={() => void loadPath(current.parent ?? "~")} hitSlop={6} > ) : ( )} {current?.path ?? "~"} {!loading ? ( {folderCount === 0 ? "No subfolders" : `${folderCount} folder${folderCount === 1 ? "" : "s"}`} [styles.newFolderButton, pressed && styles.pressedDim]} onPress={openNewFolder} hitSlop={6} > New folder ) : null} {!loading && folderCount > 0 ? ( {Platform.OS !== "ios" && search ? ( setSearch("")} hitSlop={6}> ) : null} ) : null} {loading ? ( ) : folderCount === 0 ? ( ) : filteredEntries.length === 0 ? ( ) : ( item.path} style={styles.listScroll} contentContainerStyle={styles.list} keyboardShouldPersistTaps="handled" renderItem={({ item, index }) => ( void loadPath(item.path)} /> )} /> )} {localError || error ? ( {localError ?? error} ) : null} [ styles.useButton, (adding || !current) && styles.useButtonDisabled, pressed && !adding && styles.pressedDim, ]} disabled={adding || !current} onPress={() => void useFolder()} > {adding ? ( ) : ( <> {alreadyAdded ? "Open project" : "Add this folder"} )} void submitNewFolder()} /> ); } const styles = StyleSheet.create({ container: { flex: 1, }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 4, gap: 10, }, searchBar: { flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: colors.surface, borderRadius: 12, borderWidth: 1, borderColor: colors.borderSubtle, paddingHorizontal: 12, height: 40, }, searchInput: { flex: 1, color: colors.text, fontSize: 15, height: "100%", }, pathCard: { backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: colors.borderSubtle, padding: 14, gap: 10, }, upButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(147, 197, 253, 0.1)", borderRadius: 8, }, upButtonDisabled: { width: 28, height: 28, alignItems: "center", justifyContent: "center", opacity: 0.4, }, pathRow: { flexDirection: "row", alignItems: "center", gap: 8, }, path: { flex: 1, color: colors.textSecondary, fontSize: 13, lineHeight: 18, fontFamily: monoFont, }, pathMetaRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, pathMeta: { color: colors.textMuted, fontSize: 12, fontFamily: monoFont, }, newFolderButton: { flexDirection: "row", alignItems: "center", gap: 3, backgroundColor: "rgba(147, 197, 253, 0.1)", borderRadius: 8, paddingHorizontal: 9, paddingVertical: 5, }, newFolderButtonText: { color: colors.accent, fontSize: 12, fontWeight: "600", }, listScroll: { flex: 1, }, list: { margin: 16, marginBottom: 8, backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: colors.borderSubtle, overflow: "hidden", }, row: { flexDirection: "row", alignItems: "center", gap: 12, paddingLeft: 14, }, rowPressed: { backgroundColor: colors.surfaceDeep, }, rowIcon: { width: 34, height: 34, borderRadius: 9, backgroundColor: "rgba(147, 197, 253, 0.1)", alignItems: "center", justifyContent: "center", }, rowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 13, paddingRight: 14, }, rowDivider: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, folderName: { flex: 1, color: colors.text, fontSize: 16, fontWeight: "600", }, footer: { paddingHorizontal: 16, paddingTop: 8, gap: 10, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle, backgroundColor: colors.background, }, useButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, backgroundColor: colors.userBubbleBg, borderRadius: 14, minHeight: 52, }, useButtonDisabled: { opacity: 0.6, }, useButtonText: { color: colors.userBubbleText, fontWeight: "700", fontSize: 16, }, pressedDim: { opacity: 0.7, }, center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 6, padding: 32, }, empty: { flex: 1, alignItems: "center", justifyContent: "center", gap: 6, padding: 32, }, emptyIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: colors.surfaceDeep, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center", marginBottom: 4, }, emptyTitle: { color: colors.text, fontSize: 15, fontWeight: "600", }, emptyHint: { color: colors.textMuted, fontSize: 13, lineHeight: 19, textAlign: "center", maxWidth: 260, }, errorBanner: { flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: "#450a0a", borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, }, errorText: { flex: 1, color: colors.errorMuted, fontSize: 13, lineHeight: 18, }, modalOverlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.6)", alignItems: "center", justifyContent: "center", padding: 24, }, modalCard: { width: "100%", maxWidth: 340, backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: colors.borderSubtle, padding: 18, gap: 12, }, modalTitle: { color: colors.text, fontSize: 16, fontWeight: "700", }, modalInput: { backgroundColor: colors.surfaceDeep, borderRadius: 10, borderWidth: 1, borderColor: colors.border, color: colors.text, fontSize: 15, paddingHorizontal: 12, paddingVertical: 10, }, modalError: { color: colors.errorMuted, fontSize: 13, lineHeight: 18, }, modalActions: { flexDirection: "row", gap: 10, }, modalButton: { flex: 1, alignItems: "center", justifyContent: "center", minHeight: 44, borderRadius: 12, borderWidth: 1, borderColor: colors.border, }, modalButtonPrimary: { backgroundColor: colors.userBubbleBg, borderColor: colors.userBubbleBg, }, modalButtonText: { color: colors.text, fontSize: 15, fontWeight: "600", }, modalButtonTextPrimary: { color: colors.userBubbleText, fontSize: 15, fontWeight: "700", }, });