import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, Pressable, ScrollView, Text, TextInput, View, type PressableStateCallbackType, } from "react-native"; import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { shortenPath } from "@/utils/shorten-path"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useOpenProject } from "@/hooks/use-open-project"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { isNative } from "@/constants/platform"; import { useActiveServerId } from "@/hooks/use-active-server-id"; interface PathRowProps { path: string; active: boolean; onSelect: (path: string) => void; } function PathRow({ path, active, onSelect }: PathRowProps) { const { theme } = useUnistyles(); const handlePress = useCallback(() => { onSelect(path); }, [onSelect, path]); const pressableStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, (Boolean(hovered) || pressed || active) && { backgroundColor: theme.colors.surface1, }, ], [active, theme.colors.surface1], ); const rowTextStyle = useMemo( () => [styles.rowText, { color: theme.colors.foreground }], [theme.colors.foreground], ); return ( {shortenPath(path)} ); } export function ProjectPickerModal() { const { theme } = useUnistyles(); const serverId = useActiveServerId(); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const recommendedPaths = useRecommendedProjectPaths(serverId); const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const openProject = useOpenProject(serverId); const directorySuggestionsQuery = useQuery({ queryKey: ["project-picker-directory-suggestions", serverId, query], queryFn: async () => { if (!client) return []; const result = await client.getDirectorySuggestions({ query, includeDirectories: true, includeFiles: false, limit: 30, }); return ( result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? [] ); }, enabled: Boolean(client) && isConnected && open, staleTime: 15_000, retry: false, }); const options = useMemo( () => buildWorkingDirectorySuggestions({ recommendedPaths, serverPaths: directorySuggestionsQuery.data ?? [], query, }), [query, directorySuggestionsQuery.data, recommendedPaths], ); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); const handleSelectPath = useCallback( async (path: string) => { const trimmed = path.trim(); if (!trimmed || !client || !serverId) return; setIsSubmitting(true); try { const didOpenProject = await openProject(trimmed); if (didOpenProject) { setOpen(false); } } finally { setIsSubmitting(false); } }, [client, openProject, serverId, setOpen], ); const handleSubmitCustom = useCallback(() => { const trimmed = query.trim(); if (!trimmed) return; void handleSelectPath(trimmed); }, [handleSelectPath, query]); const handleChangeQuery = useCallback((text: string) => { setQuery(text); setActiveIndex(0); }, []); // Reset state when opening/closing useEffect(() => { if (open) { setQuery(""); setActiveIndex(0); const id = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(id); } }, [open]); // Clamp active index useEffect(() => { if (!open) return; if (activeIndex >= options.length) { setActiveIndex(options.length > 0 ? options.length - 1 : 0); } }, [activeIndex, options.length, open]); // Keyboard navigation useEffect(() => { if (!open || isNative) return; function handler(event: KeyboardEvent) { const key = event.key; if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return; if (key === "Escape") { event.preventDefault(); setOpen(false); return; } if (key === "Enter") { event.preventDefault(); if (options.length > 0 && activeIndex < options.length) { void handleSelectPath(options[activeIndex]); } else if (query.trim()) { handleSubmitCustom(); } return; } if (key === "ArrowDown" || key === "ArrowUp") { if (options.length === 0) return; event.preventDefault(); setActiveIndex((current) => { const delta = key === "ArrowDown" ? 1 : -1; const next = current + delta; if (next < 0) return options.length - 1; if (next >= options.length) return 0; return next; }); } } window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]); const panelStyle = useMemo( () => [ styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0, }, ], [theme.colors.border, theme.colors.surface0], ); const headerStyle = useMemo( () => [styles.header, { borderBottomColor: theme.colors.border }], [theme.colors.border], ); const inputStyle = useMemo( () => [styles.input, { color: theme.colors.foreground }], [theme.colors.foreground], ); const emptyTextStyle = useMemo( () => [styles.emptyText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); if (!serverId) return null; return ( {isSubmitting ? Opening project... : null} {!isSubmitting && options.length === 0 && !query.trim() ? ( Start typing a path ) : null} {!isSubmitting && !(options.length === 0 && !query.trim()) ? ( <> {options.map((path, index) => ( ))} ) : null} ); } const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, justifyContent: "flex-start", alignItems: "center", paddingTop: theme.spacing[12], }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, panel: { width: 640, maxWidth: "92%", maxHeight: "80%", borderWidth: 1, borderRadius: theme.borderRadius.lg, overflow: "hidden", ...theme.shadow.lg, }, header: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderBottomWidth: 1, }, input: { fontSize: theme.fontSize.lg, paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, results: { flexGrow: 0, }, resultsContent: { paddingVertical: theme.spacing[2], }, row: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2], }, rowContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center", }, rowText: { fontSize: theme.fontSize.base, fontWeight: "400", lineHeight: 20, flexShrink: 1, }, emptyText: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], fontSize: theme.fontSize.base, }, }));