Files
pi-mobile/apps/mobile/app/picker.tsx
T
owenandCursor bb9999de58 Package Pi Mobile for developer distribution.
Publish the hub as pi-mobile-hub with CLI flags and LAN pairing auth, add mobile deep-link pairing and EAS build config, and expand gitignore for Expo/EAS artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:54:09 -05:00

668 lines
17 KiB
TypeScript

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 (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.rowIcon}>
<Ionicons name="folder-outline" size={18} color={colors.accent} />
</View>
<View style={[styles.rowMain, !isLast && styles.rowDivider]}>
<Text style={styles.folderName} numberOfLines={1}>
{item.name}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</Pressable>
);
}
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 (
<Modal visible={visible} animationType="fade" transparent onRequestClose={onCancel}>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>New folder</Text>
<TextInput
value={name}
onChangeText={onChangeName}
placeholder="Folder name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
style={styles.modalInput}
onSubmitEditing={onCreate}
/>
{error ? <Text style={styles.modalError}>{error}</Text> : null}
<View style={styles.modalActions}>
<Pressable
style={({ pressed }) => [styles.modalButton, pressed && styles.pressedDim]}
onPress={onCancel}
disabled={creating}
>
<Text style={styles.modalButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.modalButton,
styles.modalButtonPrimary,
(creating || !name.trim()) && styles.useButtonDisabled,
pressed && !creating && styles.pressedDim,
]}
onPress={onCreate}
disabled={creating || !name.trim()}
>
{creating ? (
<ActivityIndicator color={colors.userBubbleText} size="small" />
) : (
<Text style={styles.modalButtonTextPrimary}>Create</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
function EmptyFolders() {
return (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<Ionicons name="folder-open-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No folders here</Text>
<Text style={styles.emptyHint}>This directory has no subfolders to browse.</Text>
</View>
);
}
function NoSearchResults({ query }: { query: string }) {
return (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<Ionicons name="search-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No matches</Text>
<Text style={styles.emptyHint}>No folders match "{query}".</Text>
</View>
);
}
export default function FolderPickerScreen() {
const { baseUrl, token, addProject, savedProjects, error } = useSocket();
const insets = useSafeAreaInsets();
const [current, setCurrent] = useState<FolderListResult | null>(null);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [newFolderVisible, setNewFolderVisible] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderError, setNewFolderError] = useState<string | null>(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, token)) as FolderListResult;
setCurrent(result);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load folders";
setLocalError(message);
} finally {
setLoading(false);
}
},
[baseUrl, token],
);
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, token);
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 (
<View style={styles.center}>
<View style={styles.emptyIcon}>
<Ionicons name="cloud-offline-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>Not connected</Text>
<Text style={styles.emptyHint}>Connect to a host first.</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.pathCard}>
<View style={styles.pathRow}>
{current?.parent ? (
<Pressable
style={({ pressed }) => [styles.upButton, pressed && styles.pressedDim]}
onPress={() => void loadPath(current.parent ?? "~")}
hitSlop={6}
>
<Ionicons name="chevron-back" size={18} color={colors.accent} />
</Pressable>
) : (
<View style={styles.upButtonDisabled}>
<Ionicons name="chevron-back" size={18} color={colors.textMuted} />
</View>
)}
<Ionicons name="folder-outline" size={14} color={colors.textMuted} />
<Text style={styles.path} numberOfLines={2}>
{current?.path ?? "~"}
</Text>
</View>
{!loading ? (
<View style={styles.pathMetaRow}>
<Text style={styles.pathMeta}>
{folderCount === 0
? "No subfolders"
: `${folderCount} folder${folderCount === 1 ? "" : "s"}`}
</Text>
<Pressable
style={({ pressed }) => [styles.newFolderButton, pressed && styles.pressedDim]}
onPress={openNewFolder}
hitSlop={6}
>
<Ionicons name="add" size={14} color={colors.accent} />
<Text style={styles.newFolderButtonText}>New folder</Text>
</Pressable>
</View>
) : null}
</View>
{!loading && folderCount > 0 ? (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={16} color={colors.textMuted} />
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search folders"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.searchInput}
returnKeyType="search"
clearButtonMode="while-editing"
/>
{Platform.OS !== "ios" && search ? (
<Pressable onPress={() => setSearch("")} hitSlop={6}>
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
) : null}
</View>
{loading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.text} />
</View>
) : folderCount === 0 ? (
<EmptyFolders />
) : filteredEntries.length === 0 ? (
<NoSearchResults query={search.trim()} />
) : (
<FlatList
data={filteredEntries}
keyExtractor={(item) => item.path}
style={styles.listScroll}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
renderItem={({ item, index }) => (
<FolderRow
item={item}
isLast={index === filteredEntries.length - 1}
onPress={() => void loadPath(item.path)}
/>
)}
/>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
{localError || error ? (
<View style={styles.errorBanner}>
<Ionicons name="alert-circle-outline" size={16} color={colors.errorMuted} />
<Text style={styles.errorText}>{localError ?? error}</Text>
</View>
) : null}
<Pressable
style={({ pressed }) => [
styles.useButton,
(adding || !current) && styles.useButtonDisabled,
pressed && !adding && styles.pressedDim,
]}
disabled={adding || !current}
onPress={() => void useFolder()}
>
{adding ? (
<ActivityIndicator color={colors.userBubbleText} />
) : (
<>
<Ionicons
name={alreadyAdded ? "open-outline" : "add-circle-outline"}
size={20}
color={colors.userBubbleText}
/>
<Text style={styles.useButtonText}>
{alreadyAdded ? "Open project" : "Add this folder"}
</Text>
</>
)}
</Pressable>
</View>
<NewFolderModal
visible={newFolderVisible}
name={newFolderName}
creating={creatingFolder}
error={newFolderError}
onChangeName={setNewFolderName}
onCancel={closeNewFolder}
onCreate={() => void submitNewFolder()}
/>
</View>
);
}
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",
},
});