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.
270 lines
8.6 KiB
TypeScript
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,
|
|
},
|
|
}));
|