import { useCallback, useMemo } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjectIcons } from "@/projects/icons"; import { settingsStyles } from "@/styles/settings"; import { openProjectSettings } from "@/navigation/settings-navigation"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; interface ProjectsScreenProps { serverId: string; } interface HostProject { project: ProjectSummary; host: ProjectHostEntry; } export default function ProjectsScreen({ serverId }: ProjectsScreenProps) { const { t } = useTranslation(); const { projects, hostErrors, isLoading } = useProjects(); const hostProjects = useMemo( () => projects.flatMap((project) => project.hosts .filter((host) => host.serverId === serverId) .map((host) => ({ project, host })), ), [projects, serverId], ); const scopedErrors = hostErrors.filter((error) => error.serverId === serverId); const iconTargets = useMemo( () => hostProjects.map(({ project, host }) => ({ serverId: host.serverId, projectViewKey: project.viewKey, projectId: host.projectId, iconWorkingDir: host.repoRoot, customIconRevision: host.customIconRevision, })), [hostProjects], ); const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets, }); if (isLoading && hostProjects.length === 0) { return ( ); } if (hostProjects.length === 0) { return ( {t("sidebar.project.empty.title")} ); } return ( {scopedErrors.length > 0 ? : null} {hostProjects.map(({ project, host }, index) => ( ))} ); } function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) { const { t } = useTranslation(); return ( {errors.map((error) => ( {t("settings.projectList.hostLoadFailed", { hostName: error.serverName, message: error.message, })} ))} ); } interface ProjectRowProps { project: ProjectSummary; host: ProjectHostEntry; isFirst: boolean; iconDataUri: string | null; } function ProjectRow({ project, host, isFirst, iconDataUri }: ProjectRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); const { viewKey } = project; const { projectName } = host; const handleNavigate = useCallback(() => { openProjectSettings(host.serverId, host.projectId); }, [host.projectId, host.serverId]); const rowStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ settingsStyles.row, !isFirst && settingsStyles.rowBorder, styles.row, hovered && styles.rowHovered, pressed && styles.rowPressed, ], [isFirst], ); return ( {projectName} ); } function ProjectRowIcon({ iconDataUri, projectName, projectViewKey, }: { iconDataUri: string | null; projectName: string; projectViewKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; return ( ); } const styles = StyleSheet.create((theme) => ({ centered: { flex: 1, alignItems: "center", justifyContent: "center", padding: theme.spacing[6], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, errorsBanner: { borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, padding: theme.spacing[3], marginBottom: theme.spacing[3], gap: theme.spacing[1], }, errorsBannerText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm, }, row: { gap: theme.spacing[3], }, rowMain: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, rowHovered: { backgroundColor: theme.colors.surface2, }, rowPressed: { backgroundColor: theme.colors.surface3, }, leading: { width: 16, height: 16, alignItems: "center", justifyContent: "center", }, iconFallbackText: { fontSize: theme.fontSize.sm, }, spinnerColor: { color: theme.colors.foregroundMuted, }, }));