Files
paseo/packages/app/src/components/project-picker-modal.tsx
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

318 lines
9.6 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const pathname = usePathname();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const serverId = useMemo(() => {
const fromPath = parseServerIdFromPathname(pathname);
if (fromPath) return fromPath;
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId);
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id,
);
}, [workspaces]);
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]);
// 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]);
if (!serverId) return null;
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
style={[
styles.panel,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
ref={inputRef}
value={query}
onChangeText={(text) => {
setQuery(text);
setActiveIndex(0);
}}
placeholder="Type a directory path..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
editable={!isSubmitting}
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
<>
{options.map((path, index) => {
const active = index === activeIndex;
return (
<Pressable
key={path}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => void handleSelectPath(path)}
>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
</View>
<Text
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}
</Text>
</View>
</Pressable>
);
})}
</>
)}
</ScrollView>
</View>
</View>
</Modal>
);
}
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 any,
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,
},
}));