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>
668 lines
17 KiB
TypeScript
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",
|
|
},
|
|
});
|