Files
paseo/packages/app/src/screens/open-project-screen.tsx
T
Mohamed Boudra 5ec26f0c09 feat(app): align interface typography to base size
Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
2026-08-17 22:55:46 +02:00

270 lines
8.6 KiB
TypeScript

import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { CommunityLinks } from "@/components/community-links";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useHostChooser } from "@/hosts/host-chooser";
import { usePanelStore } from "@/stores/panel-store";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen() {
const { t } = useTranslation();
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenAddProject();
const chooseHost = useHostChooser();
const localServerId = useLocalDaemonServerId();
const [importServerId, setImportServerId] = useState<string | null>(null);
const importClient = useHostRuntimeClient(importServerId ?? "");
const openImportedProject = useOpenProject(importServerId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
const isCompactLayout = useIsCompactFormFactor();
useEffect(() => {
if (!isCompactLayout) {
openDesktopAgentList();
}
}, [isCompactLayout, openDesktopAgentList]);
const handleOpenPicker = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => {
chooseHost({
title: "Import from host",
onChooseHost: (serverId) => {
setImportServerId(serverId);
setIsImportSheetOpen(true);
},
});
}, [chooseHost]);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
if (!importServerId) return;
void (async () => {
const result = await openImportedProject(agent.cwd);
if (result.ok) {
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
}
})();
},
[importServerId, openImportedProject, router],
);
const handleOpenProviders = useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
},
});
}, [chooseHost, router]);
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title={t("openProject.tiles.addProject.title")}
description={t("openProject.tiles.addProject.description")}
onPress={handleOpenPicker}
testID="open-project-submit"
accent
/>
<HomeTile
icon={Inbox}
title={t("openProject.tiles.importSession.title")}
description={t("openProject.tiles.importSession.description")}
onPress={handleOpenImportSession}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title={t("openProject.tiles.setupProviders.title")}
description={t("openProject.tiles.setupProviders.description")}
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
/>
) : null}
</View>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={importClient}
serverId={importServerId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
</View>
);
}
interface HomeTileProps {
icon: ComponentType<{ size: number; color: string }>;
title: string;
description: string;
onPress: () => void;
testID?: string;
accent?: boolean;
}
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
const { theme } = useUnistyles();
const [hovered, setHovered] = useState(false);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.tile,
hovered && styles.tileHovered,
pressed && styles.tilePressed,
],
[hovered],
);
return (
<Pressable
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={testID}
style={pressableStyle}
>
<Icon size={20} color={iconColor} />
<View style={styles.tileText}>
<Text style={styles.tileTitle}>{title}</Text>
<Text style={styles.tileDescription}>{description}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
content: {
position: "relative",
flex: 1,
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
logo: {
marginBottom: theme.spacing[8],
},
tiles: {
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
width: "100%",
maxWidth: 452,
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
tile: {
width: { xs: "100%", md: 220 },
minHeight: { xs: 0, md: 132 },
padding: theme.spacing[4],
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
gap: theme.spacing[3],
},
tileHovered: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.borderAccent,
},
tilePressed: {
opacity: 0.85,
},
tileText: {
gap: theme.spacing[1],
},
tileTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
tileDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 18,
},
communityRow: {
position: "absolute",
left: 0,
right: 0,
bottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
md: HEADER_INNER_HEIGHT + theme.spacing[2],
},
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 0,
},
}));