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",
},
});