Add a keyboard-driven project setup flow (#2097)

* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
This commit is contained in:
Mohamed Boudra
2026-07-15 21:16:10 +02:00
committed by GitHub
parent 90e0a0e353
commit dfe3330ef8
52 changed files with 3851 additions and 1023 deletions
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,8 @@ import {
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
import { isNative, isWeb } from "@/constants/platform";
import { AddProjectFlow } from "@/components/add-project-flow";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
IsolatedBottomSheetModal,
@@ -336,6 +338,7 @@ export function CommandCenter() {
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const addProjectRequest = useAddProjectFlowStore((state) => state.request);
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
@@ -490,6 +493,16 @@ export function CommandCenter() {
const snapPoints = useMemo(() => ["60%", "90%"], []);
if (open && addProjectRequest) {
return (
<AddProjectFlow
key={addProjectRequest.id}
request={addProjectRequest}
onClose={handleClose}
/>
);
}
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
+2 -2
View File
@@ -34,7 +34,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
@@ -158,7 +158,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker();
const openProjectPicker = useOpenAddProject();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -1,45 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { pickDirectory } from "@/desktop/pick-directory";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton({
serverId,
disabled,
onSelect,
onError,
}: ProjectPickerBrowseButtonProps) {
const { t } = useTranslation();
const isLocalDaemon = useIsLocalDaemon(serverId);
const handlePress = useCallback(() => {
void (async () => {
try {
const path = await pickDirectory();
if (path) {
onSelect(path);
}
} catch {
onError();
}
})();
}, [onError, onSelect]);
if (!isLocalDaemon) {
return null;
}
return (
<Button
variant="outline"
size="sm"
leftIcon={FolderOpen}
disabled={disabled}
onPress={handlePress}
>
{t("projectPicker.browse")}
</Button>
);
}
@@ -1,5 +0,0 @@
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
return null;
}
@@ -1,6 +0,0 @@
export interface ProjectPickerBrowseButtonProps {
serverId: string;
disabled: boolean;
onSelect: (path: string) => void;
onError: () => void;
}
@@ -1,869 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction,
} from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
type StyleProp,
type TextStyle,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Folder, Github } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
getOpenProjectFailureReason,
type OpenProjectFailureReason,
type WorkspaceGithubCloneProtocol,
} from "@/hooks/open-project";
import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { shortenPath } from "@/utils/shorten-path";
import { isNative } from "@/constants/platform";
import { ProjectPickerBrowseButton } from "./project-picker-browse-button";
import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote";
import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options";
type ProjectPickerMode = "local" | "github";
const DEFAULT_CLONE_TARGET_DIRECTORY = "~/workspace";
const CLONE_REPO_ERROR_MESSAGE = "Unable to clone that GitHub repository.";
const CLONE_PROTOCOL_ERROR_MESSAGE = "Choose HTTPS or SSH for owner/repo repository names.";
interface PathRowProps {
option: ProjectPickerOption;
active: boolean;
onSelect: (path: string) => void;
}
function PathRow({ option, active, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const path = option.path;
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],
);
const rowActionTextStyle = useMemo(
() => [styles.rowActionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{shortenPath(path)}
</Text>
{option.kind === "path" ? (
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
) : null}
</View>
</Pressable>
);
}
interface ProjectPickerResultsProps {
options: ProjectPickerOption[];
activeIndex: number;
isSubmitting: boolean;
openErrorMessage: string | null;
hasQuery: boolean;
isSearching: boolean;
emptyTextStyle: StyleProp<TextStyle>;
errorTextStyle: StyleProp<TextStyle>;
onSelect: (path: string) => void;
}
function ProjectPickerResults({
options,
activeIndex,
isSubmitting,
openErrorMessage,
hasQuery,
isSearching,
emptyTextStyle,
errorTextStyle,
onSelect,
}: ProjectPickerResultsProps) {
const { t } = useTranslation();
const canShowResultState = !isSubmitting && !openErrorMessage;
return (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{canShowResultState && options.length === 0 && !hasQuery ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{canShowResultState && isSearching ? (
<Text style={emptyTextStyle}>{t("projectPicker.searching")}</Text>
) : null}
{canShowResultState && !isSearching && options.length === 0 && hasQuery ? (
<Text style={emptyTextStyle}>{t("common.empty.noOptionsMatchSearch")}</Text>
) : null}
{canShowResultState && options.length > 0 ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={onSelect}
/>
))}
</>
) : null}
</ScrollView>
);
}
interface CloneProtocolButtonProps {
label: string;
active: boolean;
onPress: () => void;
}
function CloneProtocolButton({ label, active, onPress }: CloneProtocolButtonProps) {
const { theme } = useUnistyles();
const buttonStyle = useMemo(
() => [
styles.protocolButton,
{
borderColor: theme.colors.border,
backgroundColor: active ? theme.colors.surface1 : "transparent",
},
],
[active, theme.colors.border, theme.colors.surface1],
);
const textStyle = useMemo(
() => [
styles.protocolButtonText,
{ color: active ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[active, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Pressable style={buttonStyle} onPress={onPress} accessibilityRole="button">
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
interface GithubRepoFormProps {
repo: string;
cloneProtocol: WorkspaceGithubCloneProtocol | null;
targetDirectory: string;
needsCloneProtocol: boolean;
isSubmitting: boolean;
repoInputRef: RefObject<TextInput | null>;
onChangeRepo: (text: string) => void;
onChangeTargetDirectory: (text: string) => void;
onSelectHttps: () => void;
onSelectSsh: () => void;
onSubmit: () => void;
}
function GithubRepoForm({
repo,
cloneProtocol,
targetDirectory,
needsCloneProtocol,
isSubmitting,
repoInputRef,
onChangeRepo,
onChangeTargetDirectory,
onSelectHttps,
onSelectSsh,
onSubmit,
}: GithubRepoFormProps) {
const { theme } = useUnistyles();
const inputStyle = useMemo(
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const labelStyle = useMemo(
() => [styles.label, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.githubForm}>
<View style={styles.fieldGroup}>
<Text style={labelStyle}>GitHub repo</Text>
<TextInput
ref={repoInputRef}
value={repo}
onChangeText={onChangeRepo}
placeholder="owner/repo"
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="next"
/>
</View>
{needsCloneProtocol ? (
<View style={styles.fieldGroup}>
<Text style={labelStyle}>Clone protocol</Text>
<View style={styles.protocolRow}>
<CloneProtocolButton
label="HTTPS"
active={cloneProtocol === "https"}
onPress={onSelectHttps}
/>
<CloneProtocolButton
label="SSH"
active={cloneProtocol === "ssh"}
onPress={onSelectSsh}
/>
</View>
</View>
) : null}
<View style={styles.fieldGroup}>
<Text style={labelStyle}>Checkout directory</Text>
<TextInput
value={targetDirectory}
onChangeText={onChangeTargetDirectory}
placeholder={DEFAULT_CLONE_TARGET_DIRECTORY}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={onSubmit}
/>
</View>
</View>
);
}
interface ProjectPickerKeyboardNavigationInput {
open: boolean;
mode: ProjectPickerMode;
optionsLength: number;
onClose: () => void;
setActiveIndex: Dispatch<SetStateAction<number>>;
}
function useProjectPickerKeyboardNavigation({
open,
mode,
optionsLength,
onClose,
setActiveIndex,
}: ProjectPickerKeyboardNavigationInput) {
useEffect(() => {
if (!open || isNative) return;
function handler(event: KeyboardEvent) {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (mode !== "local" || optionsLength === 0) return;
event.preventDefault();
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return optionsLength - 1;
if (next >= optionsLength) return 0;
return next;
});
}
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [mode, onClose, open, optionsLength, setActiveIndex]);
}
interface GithubCloneModeInput {
client: ReturnType<typeof useHostRuntimeClient>;
serverId: string | null;
openGithubRepo: ReturnType<typeof useOpenGithubRepo>;
close: () => void;
}
function useGithubCloneMode({ client, serverId, openGithubRepo, close }: GithubCloneModeInput) {
const [repo, setRepo] = useState("");
const [cloneProtocol, setCloneProtocol] = useState<WorkspaceGithubCloneProtocol | null>(null);
const [targetDirectory, setTargetDirectory] = useState(DEFAULT_CLONE_TARGET_DIRECTORY);
const [cloneErrorText, setCloneErrorText] = useState<string | null>(null);
const needsCloneProtocol = repo.trim().length > 0 && !isCompleteGitRemote(repo);
const reset = useCallback(() => {
setRepo("");
setCloneProtocol(null);
setTargetDirectory(DEFAULT_CLONE_TARGET_DIRECTORY);
setCloneErrorText(null);
}, []);
const handleChangeRepo = useCallback((text: string) => {
setRepo(text);
if (isCompleteGitRemote(text)) {
setCloneProtocol(null);
}
setCloneErrorText(null);
}, []);
const handleChangeTargetDirectory = useCallback((text: string) => {
setTargetDirectory(text);
setCloneErrorText(null);
}, []);
const handleSetHttpsProtocol = useCallback(() => {
setCloneProtocol("https");
setCloneErrorText(null);
}, []);
const handleSetSshProtocol = useCallback(() => {
setCloneProtocol("ssh");
setCloneErrorText(null);
}, []);
const handleCloneRepo = useCallback(async () => {
const trimmedRepo = repo.trim();
const trimmedTargetDirectory = targetDirectory.trim();
if (!trimmedRepo || !trimmedTargetDirectory || !client || !serverId) return false;
const repoIsCompleteRemote = isCompleteGitRemote(trimmedRepo);
if (!repoIsCompleteRemote && !cloneProtocol) {
setCloneErrorText(CLONE_PROTOCOL_ERROR_MESSAGE);
return false;
}
setCloneErrorText(null);
const didOpenProject = await openGithubRepo(
trimmedRepo,
trimmedTargetDirectory,
repoIsCompleteRemote ? undefined : (cloneProtocol ?? undefined),
);
if (!didOpenProject) {
setCloneErrorText(CLONE_REPO_ERROR_MESSAGE);
return false;
}
close();
return true;
}, [client, cloneProtocol, close, openGithubRepo, repo, serverId, targetDirectory]);
return {
repo,
cloneProtocol,
targetDirectory,
cloneErrorText,
needsCloneProtocol,
reset,
handleChangeRepo,
handleChangeTargetDirectory,
handleSetHttpsProtocol,
handleSetSshProtocol,
handleCloneRepo,
};
}
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const request = useProjectPickerStore((state) => state.request);
const close = useProjectPickerStore((state) => state.close);
const serverId = request?.serverId ?? null;
const open = request !== null;
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const recommendedPaths = useRecommendedProjectPaths(serverId);
const inputRef = useRef<TextInput>(null);
const repoInputRef = useRef<TextInput>(null);
const [mode, setMode] = useState<ProjectPickerMode>("local");
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(null);
const openProject = useOpenProject(serverId);
const openGithubRepo = useOpenGithubRepo(serverId);
const supportsGithubClone =
client?.getLastServerInfoMessage()?.features?.workspaceGithubClone === true;
const {
repo,
cloneProtocol,
targetDirectory,
cloneErrorText,
needsCloneProtocol,
reset: resetGithubCloneMode,
handleChangeRepo,
handleChangeTargetDirectory,
handleSetHttpsProtocol,
handleSetSshProtocol,
handleCloneRepo: cloneGithubRepo,
} = useGithubCloneMode({ client, serverId, openGithubRepo, close });
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery],
queryFn: async () => {
if (!client) {
return { query: debouncedQuery, paths: [] };
}
const result = await client.getDirectorySuggestions({
query: debouncedQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return {
query: debouncedQuery,
paths:
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
[],
};
},
enabled: Boolean(client) && isConnected && open && mode === "local",
staleTime: 15_000,
retry: false,
});
const options = useMemo(() => {
const currentSuggestions =
directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null;
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: currentSuggestions?.paths ?? [],
query,
});
}, [directorySuggestionsQuery.data, query, recommendedPaths]);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
options.length === 0 &&
(query !== debouncedQuery || directorySuggestionsQuery.isFetching);
const openErrorMessage = useMemo(() => {
if (!openErrorReason) {
return null;
}
return t(`projectPicker.errors.${openErrorReason}`);
}, [openErrorReason, t]);
const handleClose = useCallback(() => {
close();
}, [close]);
const handleSelectPath = useCallback(
async (path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
setOpenErrorReason(null);
setIsSubmitting(true);
try {
const result = await openProject(trimmed);
if (result.ok) {
close();
return;
}
setOpenErrorReason(getOpenProjectFailureReason(result));
} catch {
setOpenErrorReason("open_failed");
} finally {
setIsSubmitting(false);
}
},
[client, close, openProject, serverId],
);
const handleCloneRepo = useCallback(async () => {
setIsSubmitting(true);
try {
await cloneGithubRepo();
} finally {
setIsSubmitting(false);
}
}, [cloneGithubRepo]);
const submitActiveOption = useCallback(() => {
if (mode === "github") {
void handleCloneRepo();
return;
}
const option = options[activeIndex];
if (!option) return;
void handleSelectPath(option.path);
}, [activeIndex, handleCloneRepo, handleSelectPath, mode, options]);
const handleChangeQuery = useCallback((text: string) => {
setQuery(text);
setActiveIndex(0);
setOpenErrorReason(null);
}, []);
const handleBrowseError = useCallback(() => {
setOpenErrorReason("open_failed");
}, []);
const handleSetLocalMode = useCallback(() => {
setMode("local");
setActiveIndex(0);
}, []);
const handleSetGithubMode = useCallback(() => {
setMode("github");
setActiveIndex(0);
setOpenErrorReason(null);
}, []);
useEffect(() => {
if (open) {
setMode("local");
setQuery("");
setDebouncedQuery("");
resetGithubCloneMode();
setActiveIndex(0);
setOpenErrorReason(null);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}
}, [open, resetGithubCloneMode]);
useEffect(() => {
if (!open) return;
const id = setTimeout(() => {
if (mode === "github") {
repoInputRef.current?.focus();
return;
}
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [mode, open]);
// Debounce the query that drives the (potentially multi-second) directory
// suggestions RPC so fast typing doesn't fire a filesystem scan per keystroke.
useEffect(() => {
const id = setTimeout(() => setDebouncedQuery(query), 250);
return () => clearTimeout(id);
}, [query]);
useEffect(() => {
if (!open || mode !== "local") return;
if (activeIndex >= options.length) {
setActiveIndex(options.length > 0 ? options.length - 1 : 0);
}
}, [activeIndex, mode, options.length, open]);
useProjectPickerKeyboardNavigation({
open,
mode,
optionsLength: options.length,
onClose: close,
setActiveIndex,
});
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],
);
const errorTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const cloneErrorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const modeButtonStyle = useCallback(
(buttonMode: ProjectPickerMode) => [
styles.modeButton,
{
borderColor: theme.colors.border,
backgroundColor: mode === buttonMode ? theme.colors.surface1 : "transparent",
},
],
[mode, theme.colors.border, theme.colors.surface1],
);
const modeButtonTextStyle = useCallback(
(buttonMode: ProjectPickerMode) => [
styles.modeButtonText,
{ color: mode === buttonMode ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[mode, theme.colors.foreground, theme.colors.foregroundMuted],
);
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={panelStyle}>
<View style={headerStyle}>
<View style={styles.modeRow}>
<Pressable style={modeButtonStyle("local")} onPress={handleSetLocalMode}>
<Folder size={15} color={theme.colors.foregroundMuted} />
<Text style={modeButtonTextStyle("local")}>Local folder</Text>
</Pressable>
{supportsGithubClone ? (
<Pressable style={modeButtonStyle("github")} onPress={handleSetGithubMode}>
<Github size={15} color={theme.colors.foregroundMuted} />
<Text style={modeButtonTextStyle("github")}>GitHub repo</Text>
</Pressable>
) : null}
</View>
{mode === "local" ? (
<View style={styles.localInputRow}>
<TextInput
testID="project-picker-input"
ref={inputRef}
value={query}
onChangeText={handleChangeQuery}
placeholder={t("projectPicker.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={submitActiveOption}
/>
<ProjectPickerBrowseButton
serverId={serverId}
disabled={isSubmitting}
onSelect={handleSelectPath}
onError={handleBrowseError}
/>
</View>
) : (
<GithubRepoForm
repo={repo}
cloneProtocol={cloneProtocol}
targetDirectory={targetDirectory}
needsCloneProtocol={needsCloneProtocol}
isSubmitting={isSubmitting}
repoInputRef={repoInputRef}
onChangeRepo={handleChangeRepo}
onChangeTargetDirectory={handleChangeTargetDirectory}
onSelectHttps={handleSetHttpsProtocol}
onSelectSsh={handleSetSshProtocol}
onSubmit={handleCloneRepo}
/>
)}
</View>
{mode === "local" ? (
<ProjectPickerResults
options={options}
activeIndex={activeIndex}
isSubmitting={isSubmitting}
openErrorMessage={openErrorMessage}
hasQuery={hasQuery}
isSearching={isSearching}
emptyTextStyle={emptyTextStyle}
errorTextStyle={errorTextStyle}
onSelect={handleSelectPath}
/>
) : (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{cloneErrorText ? <Text style={cloneErrorTextStyle}>{cloneErrorText}</Text> : null}
{isSubmitting ? <Text style={emptyTextStyle}>Cloning repository...</Text> : null}
</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,
gap: theme.spacing[3],
},
localInputRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
modeRow: {
flexDirection: "row",
gap: theme.spacing[2],
},
modeButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
},
modeButtonText: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
},
githubForm: {
gap: theme.spacing[3],
},
fieldGroup: {
gap: theme.spacing[1],
},
label: {
fontSize: theme.fontSize.xs,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0,
},
protocolRow: {
flexDirection: "row",
gap: theme.spacing[2],
},
protocolButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
},
protocolButtonText: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
},
input: {
flex: 1,
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,
flex: 1,
flexShrink: 1,
},
rowActionText: {
fontSize: theme.fontSize.xs,
fontWeight: "500",
},
emptyText: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
fontSize: theme.fontSize.base,
},
errorText: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.sm,
fontWeight: "600",
},
}));