442 lines
10 KiB
TypeScript
442 lines
10 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import {
|
|
Pressable,
|
|
RefreshControl,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { useFocusEffect } from "@react-navigation/native";
|
|
import { useSocket } from "@/contexts/SocketContext";
|
|
import { groupByProject, projectMeta, shortenPath } from "@/lib/session-utils";
|
|
import { colors, monoFont, tintFor } from "@/lib/theme";
|
|
|
|
function ProjectRow({
|
|
name,
|
|
path,
|
|
meta,
|
|
active,
|
|
isLast,
|
|
onPress,
|
|
}: {
|
|
name: string;
|
|
path: string;
|
|
meta: string;
|
|
active: boolean;
|
|
isLast: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
const tint = tintFor(name);
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
|
onPress={onPress}
|
|
>
|
|
<View style={[styles.tile, { backgroundColor: tint.bg }]}>
|
|
<Text style={[styles.tileLetter, { color: tint.fg }]}>
|
|
{name.charAt(0).toUpperCase()}
|
|
</Text>
|
|
{active ? <View style={styles.tileActiveDot} /> : null}
|
|
</View>
|
|
<View style={[styles.rowMain, !isLast && styles.rowMainDivider]}>
|
|
<View style={styles.rowText}>
|
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
|
{name}
|
|
</Text>
|
|
<Text style={styles.rowPath} numberOfLines={1}>
|
|
{path}
|
|
</Text>
|
|
<Text style={styles.rowMeta} numberOfLines={1}>
|
|
{meta}
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function EmptyState() {
|
|
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 projects yet</Text>
|
|
<Text style={styles.emptyHint}>
|
|
Add a folder from your computer to start chatting with Pi.
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function SessionsScreen() {
|
|
const {
|
|
host,
|
|
port,
|
|
connected,
|
|
error,
|
|
persistedSessions,
|
|
liveRuntimes,
|
|
savedProjects,
|
|
refreshSessions,
|
|
disconnect,
|
|
} = useSocket();
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const projects = useMemo(
|
|
() => groupByProject(savedProjects, liveRuntimes, persistedSessions),
|
|
[savedProjects, liveRuntimes, persistedSessions],
|
|
);
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
refreshSessions();
|
|
setTimeout(() => setRefreshing(false), 600);
|
|
}, [refreshSessions]);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (connected) {
|
|
refreshSessions();
|
|
}
|
|
}, [connected, refreshSessions]),
|
|
);
|
|
|
|
const openProject = (cwd: string) => {
|
|
router.push({ pathname: "/project", params: { cwd } });
|
|
};
|
|
|
|
const handleDisconnect = () => {
|
|
disconnect();
|
|
router.replace("/");
|
|
};
|
|
|
|
if (!connected) {
|
|
return (
|
|
<View style={styles.disconnected}>
|
|
<View style={styles.emptyIcon}>
|
|
<Ionicons name="cloud-offline-outline" size={22} color={colors.textMuted} />
|
|
</View>
|
|
<Text style={styles.disconnectedTitle}>Not connected</Text>
|
|
<Text style={styles.disconnectedHint}>
|
|
Connect to your computer to see your projects.
|
|
</Text>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressedDim]}
|
|
onPress={() => router.replace("/")}
|
|
>
|
|
<Text style={styles.secondaryButtonText}>Connect</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{error ? (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorBannerText}>{error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<ScrollView
|
|
contentContainerStyle={styles.scroll}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => void handleRefresh()}
|
|
tintColor={colors.text}
|
|
/>
|
|
}
|
|
>
|
|
<View style={styles.statusRow}>
|
|
<View style={styles.statusLeft}>
|
|
<View style={styles.connectionDot} />
|
|
<Text style={styles.connectionText} numberOfLines={1}>
|
|
{host}:{port}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
onPress={handleDisconnect}
|
|
hitSlop={10}
|
|
style={({ pressed }) => pressed && styles.pressedDim}
|
|
>
|
|
<Text style={styles.disconnectText}>Disconnect</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Pressable
|
|
style={({ pressed }) => [styles.addButton, pressed && styles.pressedDim]}
|
|
onPress={() => router.push("/picker")}
|
|
>
|
|
<View style={styles.addIcon}>
|
|
<Ionicons name="add" size={20} color={colors.text} />
|
|
</View>
|
|
<View style={styles.addText}>
|
|
<Text style={styles.addTitle}>Add project</Text>
|
|
<Text style={styles.addHint}>Choose a folder on your computer</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={16} color={colors.text} />
|
|
</Pressable>
|
|
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
Projects{projects.length > 0 ? ` · ${projects.length}` : ""}
|
|
</Text>
|
|
{projects.length === 0 ? (
|
|
<EmptyState />
|
|
) : (
|
|
<View style={styles.listCard}>
|
|
{projects.map((project, index) => (
|
|
<ProjectRow
|
|
key={project.cwd || "(unknown)"}
|
|
name={project.name}
|
|
path={shortenPath(project.cwd) || "Unknown location"}
|
|
meta={projectMeta(project)}
|
|
active={project.activeCount > 0}
|
|
isLast={index === projects.length - 1}
|
|
onPress={() => openProject(project.cwd)}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
scroll: {
|
|
padding: 16,
|
|
gap: 16,
|
|
paddingBottom: 40,
|
|
},
|
|
pressedDim: {
|
|
opacity: 0.6,
|
|
},
|
|
statusRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 2,
|
|
},
|
|
statusLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
flexShrink: 1,
|
|
},
|
|
connectionDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 4,
|
|
backgroundColor: colors.success,
|
|
},
|
|
connectionText: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
fontFamily: monoFont,
|
|
},
|
|
disconnectText: {
|
|
color: colors.textMuted,
|
|
fontSize: 13,
|
|
fontWeight: "600",
|
|
},
|
|
addButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
},
|
|
addIcon: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
backgroundColor: "rgba(255, 255, 255, 0.12)",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
addText: {
|
|
flex: 1,
|
|
gap: 1,
|
|
},
|
|
addTitle: {
|
|
color: colors.text,
|
|
fontSize: 16,
|
|
fontWeight: "700",
|
|
},
|
|
addHint: {
|
|
color: colors.text,
|
|
fontSize: 13,
|
|
},
|
|
section: {
|
|
gap: 10,
|
|
marginTop: 4,
|
|
},
|
|
sectionTitle: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
fontFamily: monoFont,
|
|
paddingHorizontal: 4,
|
|
},
|
|
listCard: {
|
|
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,
|
|
},
|
|
tile: {
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
tileLetter: {
|
|
fontSize: 17,
|
|
fontWeight: "700",
|
|
},
|
|
tileActiveDot: {
|
|
position: "absolute",
|
|
right: -2,
|
|
top: -2,
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: colors.success,
|
|
borderWidth: 2,
|
|
borderColor: colors.surface,
|
|
},
|
|
rowMain: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingVertical: 13,
|
|
paddingRight: 14,
|
|
},
|
|
rowMainDivider: {
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
rowText: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
rowTitle: {
|
|
color: colors.text,
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
rowPath: {
|
|
color: colors.textSecondary,
|
|
fontSize: 12,
|
|
fontFamily: monoFont,
|
|
},
|
|
rowMeta: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
},
|
|
empty: {
|
|
alignItems: "center",
|
|
gap: 6,
|
|
paddingVertical: 36,
|
|
paddingHorizontal: 24,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
},
|
|
emptyIcon: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 24,
|
|
backgroundColor: colors.surfaceDeep,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginBottom: 6,
|
|
},
|
|
emptyTitle: {
|
|
color: colors.text,
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
},
|
|
emptyHint: {
|
|
color: colors.textMuted,
|
|
fontSize: 13,
|
|
lineHeight: 19,
|
|
textAlign: "center",
|
|
},
|
|
secondaryButton: {
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 12,
|
|
alignItems: "center",
|
|
marginTop: 8,
|
|
},
|
|
secondaryButtonText: {
|
|
color: colors.text,
|
|
fontWeight: "600",
|
|
fontSize: 15,
|
|
},
|
|
disconnected: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
padding: 32,
|
|
},
|
|
disconnectedTitle: {
|
|
color: colors.text,
|
|
fontSize: 18,
|
|
fontWeight: "700",
|
|
},
|
|
disconnectedHint: {
|
|
color: colors.textMuted,
|
|
fontSize: 15,
|
|
textAlign: "center",
|
|
lineHeight: 22,
|
|
},
|
|
errorBanner: {
|
|
backgroundColor: "#450a0a",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
},
|
|
errorBannerText: {
|
|
color: colors.errorMuted,
|
|
fontSize: 14,
|
|
},
|
|
});
|