Files
paseo/packages/app/src/components/add-project-flow.tsx
T
1c4400194a Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad

Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard
drives the same bindings as web and desktop. Overlays (menus, comboboxes,
modals, command center) get first refusal on bare keys, and a focused
terminal keeps the keys it needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(keyboard): record iOS hardware key observation spike

With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA.

* refactor(keyboard): give overlays one key registration API

Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): route native hardware keys through the shared resolver

Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): show shortcuts when hardware keyboards are present

Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden.

Co-authored-by: Les Stroud <public@lesstroud.com>

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* test(keyboard): allow shortcut hints in compact menu assertions

Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label.

* fix(keyboard): retain an overlay's opening layer in state

Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop.

Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass.

* refactor(keyboard): let overlays own native hardware input

Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior.

* fix(keyboard): close Add Project outside its state updater

Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater.

The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad.

* fix(keyboard): preserve Android dead-key composition

* fix(keyboard): recognize focused native text inputs

* test(keyboard): replace JSDOM coverage with browser tests

* refactor(menu): spell out arrow navigation branches

* fix(composer): claim fullscreen Escape on native

* refactor(composer): one fullscreen Escape module for every platform

The shared registration lived in fullscreen-exit.shared.ts with two one-line
re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no
native override now that the Android back button is its own adapter, so the
implementation moves into fullscreen-exit.ts and the browser test imports the
module every platform resolves.

---------

Co-authored-by: Ubuntu <jeroenfrenken@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
2026-11-03 18:08:27 +07:00

1099 lines
37 KiB
TypeScript

import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
import {
ArrowLeft,
Folder,
FolderOpen,
FolderPlus,
HardDrive,
Plus,
Search,
Server,
} from "lucide-react-native";
import {
createElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type RefObject,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
applyAvailableAddProjectHosts,
backAddProjectPage,
chooseAddProjectHost,
currentAddProjectPage,
moveAddProjectSelection,
openAddProjectFlow,
openDirectorySearchPage,
openGithubLocationPage,
openGithubSearchPage,
openNewDirectoryNamePage,
openNewDirectoryParentPage,
setAddProjectActiveIndex,
setAddProjectPageInput,
setNewDirectoryName,
updateCurrentAddProjectPage,
type AddProjectFlowState,
type AddProjectHost,
type AddProjectPage,
type GithubRepositoryChoice,
} from "@/add-project-flow/model";
import {
buildAddProjectMethods,
addProjectMethodEmptyText,
buildCloneLocationOptions,
buildManualGithubRepositoryChoices,
buildSuggestedParentDirectories,
filterAddProjectHosts,
joinDirectoryPath,
pathBaseName,
type AddProjectMethodId,
} from "@/add-project-flow/options";
import {
buildProjectPickerOptions,
type ProjectPickerOption,
} from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut";
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { getIsElectronRuntime } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory";
import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
import {
useHosts,
useHostRuntimeClient,
useHostRuntimeConnectionStatuses,
} from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useSessionStore } from "@/stores/session-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
onClose: () => void;
}
interface FlowRowOption {
id: string;
title: string;
subtitle: string | null;
icon: ComponentType<{ size?: number; color?: string }>;
disabled?: boolean;
testID: string;
select: () => void;
}
type GithubLocationPage = Extract<AddProjectPage, { kind: "github-location" }>;
interface FlowIconProps {
icon: ComponentType<{ size?: number; color?: string }>;
size?: number;
color?: string;
}
function FlowIcon({ icon: Icon, size, color }: FlowIconProps) {
return <Icon size={size} color={color} />;
}
const MutedFlowIcon = withUnistyles(FlowIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const lastCloneParentByHost = new Map<string, string>();
const EMPTY_PATHS: string[] = [];
const NAVIGATION_HINT_KEYS = ["Up", "Down"];
const SELECT_HINT_KEYS = ["Enter"];
const ESCAPE_HINT_KEYS = ["Esc"];
function FlowBackButton({ onPress }: { onPress: () => void }) {
return (
<Pressable
onPress={onPress}
hitSlop={8}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
testID="add-project-flow-back"
>
{({ hovered, pressed }) => (
<ThemedArrowLeft
size={18}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</Pressable>
);
}
function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] {
if (method === "github") return GitHubIcon;
if (method === "browse") return FolderOpen;
if (method === "new-directory") return FolderPlus;
return Search;
}
function directoryOptionSubtitle(option: ProjectPickerOption, shortPath: string): string | null {
if (option.kind === "path") return "Open this path";
if (shortPath === option.path) return null;
return option.path;
}
function progressText(page: AddProjectPage): string {
if (page.kind === "github-location") return "Cloning project...";
if (page.kind === "new-directory-name") return "Creating directory...";
return "Adding project...";
}
function emptyText(page: AddProjectPage, host: AddProjectHost | null): string {
if (page.kind === "host") return "No connected hosts";
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
if (page.kind === "method") return addProjectMethodEmptyText(host);
return "No matching options";
}
interface QueryErrorInput {
searchesDirectories: boolean;
directoryFailed: boolean;
githubFailed: boolean;
githubAvailable: boolean | null;
githubError: string | null;
}
function queryErrorText(input: QueryErrorInput): string | null {
if (input.searchesDirectories && input.directoryFailed) return "Unable to search directories";
if (input.githubFailed) return "Unable to search GitHub repositories";
if (input.githubError) return input.githubError;
if (input.githubAvailable === false) return input.githubError ?? "GitHub search is unavailable";
return null;
}
function pageHostId(page: AddProjectPage): string | null {
return page.kind === "host" ? null : page.hostId;
}
function pageTitle(page: AddProjectPage): string {
switch (page.kind) {
case "host":
return "Choose host";
case "method":
return "Add project";
case "directory-search":
return "Search for directory";
case "github-search":
return "Clone from GitHub";
case "github-location":
return "Choose destination";
case "new-directory-parent":
return "Choose parent directory";
case "new-directory-name":
return "Name directory";
}
}
type AddProjectInputPage = Exclude<AddProjectPage, { kind: "method" }>;
function pagePlaceholder(page: AddProjectInputPage): string {
switch (page.kind) {
case "host":
return "Search hosts...";
case "directory-search":
return "Search directories or enter a path...";
case "github-search":
return "Search or enter a GitHub repository...";
case "github-location":
case "new-directory-parent":
return "Search parent directories or enter a path...";
case "new-directory-name":
return "Directory name";
}
}
function pageInput(page: AddProjectInputPage): string {
return page.kind === "new-directory-name" ? page.name : page.query;
}
function pathTestId(path: string): string {
return `add-project-flow-path-${encodeURIComponent(path)}`;
}
function FlowRow({
option,
active,
activeItemRef,
onRowLayout,
}: {
option: FlowRowOption;
active: boolean;
activeItemRef: RefObject<View | null>;
onRowLayout: () => void;
}) {
const accessibilityState = useMemo(
() => ({ disabled: option.disabled === true, selected: active }),
[active, option.disabled],
);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(active || hovered || pressed) && styles.rowActive,
option.disabled && styles.disabled,
],
[active, option.disabled],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={option.disabled}
ref={active ? activeItemRef : undefined}
onLayout={active ? onRowLayout : undefined}
onPress={option.select}
style={rowStyle}
testID={option.testID}
>
<View style={styles.iconSlot}>
<MutedFlowIcon icon={option.icon} size={16} />
</View>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{option.title}
</Text>
{option.subtitle ? (
<Text style={styles.rowSubtitle} numberOfLines={1}>
{option.subtitle}
</Text>
) : null}
</View>
</Pressable>
);
}
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
const shortcutsAvailable = useHardwareKeyboardPresent();
if (!shortcutsAvailable) return null;
return (
<View style={styles.footerHint}>
<Shortcut keys={keys} textStyle={styles.footerKeyText} />
<Text style={styles.footerAction}>{action}</Text>
</View>
);
}
function setPageStatus(
state: AddProjectFlowState,
kind: AddProjectPage["kind"],
input: { isSubmitting?: boolean; error?: string | null },
): AddProjectFlowState {
return updateCurrentAddProjectPage(state, (page) =>
page.kind === kind ? { ...page, ...input } : page,
);
}
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity");
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
const githubSearchByHost = useHostFeatureMap(hostIds, "workspaceGithubRepositorySearch");
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
const createDirectoryByHost = useHostFeatureMap(hostIds, "projectCreateDirectory");
const localServerId = useLocalDaemonServerId();
const availableHosts = useMemo<AddProjectHost[]>(
() =>
hosts.flatMap((host) => {
if (connectionStatuses.get(host.serverId) !== "online") return [];
const canAddProject =
projectAddByHost.get(host.serverId) === true &&
stableProjectIdentityByHost.get(host.serverId) === true;
return [
{
serverId: host.serverId,
label: host.label,
canAddProject,
canBrowse: canAddProject && getIsElectronRuntime() && localServerId === host.serverId,
canCloneGithubRepositories: githubCloneByHost.get(host.serverId) === true,
canSearchGithubRepositories: githubSearchByHost.get(host.serverId) === true,
canCreateDirectory: createDirectoryByHost.get(host.serverId) === true,
},
];
}),
[
connectionStatuses,
createDirectoryByHost,
githubCloneByHost,
githubSearchByHost,
hosts,
localServerId,
projectAddByHost,
stableProjectIdentityByHost,
],
);
const [state, setState] = useState(() =>
openAddProjectFlow({
hosts: availableHosts,
...(request.preferredHostId ? { preferredHostId: request.preferredHostId } : {}),
}),
);
const page = currentAddProjectPage(state);
const hostId = pageHostId(page);
const host = hostId ? state.hosts.find((candidate) => candidate.serverId === hostId) : null;
const client = useHostRuntimeClient(hostId ?? "");
const isLocalDaemon = useIsLocalDaemon(hostId ?? "");
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const upsertProject = useCallback(
(
targetServerId: string,
project: Parameters<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
) => {
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
},
[],
);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<EditingTextInputHandle>(null);
const resultsRef = useRef<ScrollView>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
const pageInputValueRef = useRef(page.kind === "method" ? "" : pageInput(page));
pageInputValueRef.current = page.kind === "method" ? "" : pageInput(page);
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
setState((current) =>
applyAvailableAddProjectHosts(current, availableHosts, request.preferredHostId),
);
}, [availableHosts, request.preferredHostId]);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 250);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
inputRef.current?.replaceText(pageInputValueRef.current);
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}, [page.kind]);
const searchesDirectories =
page.kind === "directory-search" ||
page.kind === "github-location" ||
page.kind === "new-directory-parent";
const directoryQuery = useFetchQuery({
queryKey: ["add-project-flow-directories", hostId, debouncedQuery],
queryFn: async () => {
if (!client) return { query: debouncedQuery, paths: [] as string[] };
const payload = await client.getDirectorySuggestions({
query: debouncedQuery,
includeDirectories: true,
includeFiles: false,
limit: 30,
});
return {
query: debouncedQuery,
paths:
payload.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ??
[],
};
},
enabled: Boolean(client && searchesDirectories),
dataShape: "value",
retry: false,
staleTimeMs: 15_000,
});
const githubQuery = useFetchQuery({
queryKey: ["add-project-flow-github", hostId, debouncedQuery],
queryFn: async () => {
if (!client) throw new Error("Host is unavailable");
const payload = await client.searchGithubRepositories({ query: debouncedQuery, limit: 30 });
return { query: debouncedQuery, payload };
},
enabled: Boolean(client && page.kind === "github-search" && host?.canSearchGithubRepositories),
dataShape: "value",
retry: false,
staleTimeMs: 15_000,
});
const handleBack = useCallback(() => {
const previous = backAddProjectPage(state);
if (previous) setState(previous);
else onClose();
}, [onClose, state]);
const openNewWorkspaceForProject = useCallback(
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
onClose();
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId,
projectId: project.projectId,
sourceDirectory: project.projectRootPath,
displayName: project.projectDisplayName,
}),
);
},
[onClose],
);
const openAddedProject = useCallback(
async (path: string, sourceKind: "directory-search" | "method") => {
if (!hostId || submissionInFlightRef.current) return;
submissionInFlightRef.current = true;
setState((current) =>
setPageStatus(current, sourceKind, { isSubmitting: true, error: null }),
);
try {
const result = await openProject(path);
if (result.ok) {
openNewWorkspaceForProject(hostId, result.project);
return;
}
const reason = getOpenProjectFailureReason(result);
const message =
reason === "directory_not_found" ? "Directory not found" : "Unable to add project";
setState((current) =>
setPageStatus(current, sourceKind, { isSubmitting: false, error: message }),
);
} catch {
setState((current) =>
setPageStatus(current, sourceKind, {
isSubmitting: false,
error: "Unable to add project",
}),
);
} finally {
submissionInFlightRef.current = false;
}
},
[hostId, openNewWorkspaceForProject, openProject],
);
const browse = useCallback(async () => {
if (!hostId || !isLocalDaemon || browseInFlightRef.current) return;
browseInFlightRef.current = true;
try {
const path = await pickDirectory();
if (path) await openAddedProject(path, "method");
} catch {
setState((current) =>
setPageStatus(current, "method", { error: "Unable to browse for a directory" }),
);
} finally {
browseInFlightRef.current = false;
}
}, [hostId, isLocalDaemon, openAddedProject]);
const selectMethod = useCallback(
(method: AddProjectMethodId) => {
if (!hostId) return;
if (method === "directory-search") {
setState((current) => openDirectorySearchPage(current, hostId));
} else if (method === "browse") {
void browse();
} else if (method === "github") {
setState((current) => openGithubSearchPage(current, hostId));
} else {
setState((current) => openNewDirectoryParentPage(current, hostId));
}
},
[browse, hostId],
);
const directoryPaths = useMemo(
() => (directoryQuery.data?.query === query ? directoryQuery.data.paths : EMPTY_PATHS),
[directoryQuery.data, query],
);
const pathOptions = useMemo(
() =>
buildProjectPickerOptions({
recommendedPaths,
serverPaths: directoryPaths,
query,
}),
[directoryPaths, query, recommendedPaths],
);
const cloneRepository = useCallback(
async (locationPage: GithubLocationPage, parentPath: string) => {
if (submissionInFlightRef.current) return;
submissionInFlightRef.current = true;
setState((current) =>
setPageStatus(current, "github-location", { isSubmitting: true, error: null }),
);
try {
const result = await cloneGithubProject(
locationPage.repository.cloneUrl,
parentPath,
locationPage.repository.cloneProtocol,
);
if (result.ok) {
lastCloneParentByHost.set(locationPage.hostId, parentPath);
openNewWorkspaceForProject(locationPage.hostId, result.project);
return;
}
setState((current) =>
setPageStatus(current, "github-location", {
isSubmitting: false,
error: result.error ?? "Unable to clone repository",
}),
);
} catch (error) {
setState((current) =>
setPageStatus(current, "github-location", {
isSubmitting: false,
error: error instanceof Error ? error.message : "Unable to clone repository",
}),
);
} finally {
submissionInFlightRef.current = false;
}
},
[cloneGithubProject, openNewWorkspaceForProject],
);
const rows = useMemo<FlowRowOption[]>(() => {
if (page.kind === "host") {
const choices = filterAddProjectHosts(state.hosts, page.query).map<FlowRowOption>(
(choice) => ({
id: choice.serverId,
title: choice.label,
subtitle: choice.serverId,
icon: Server,
testID: `add-project-flow-host-${choice.serverId}`,
select: () => setState((current) => chooseAddProjectHost(current, choice.serverId)),
}),
);
if (state.hosts.length === 0) {
choices.push({
id: "add-host",
title: "Add host",
subtitle: "No connected hosts",
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
addHost();
},
});
}
return choices;
}
if (page.kind === "method") {
if (!host) return [];
return buildAddProjectMethods(host).map((method) => ({
id: method.id,
title: method.label,
subtitle: method.description,
icon: methodIcon(method.id),
disabled: method.disabled,
testID: `add-project-flow-method-${method.id}`,
select: () => selectMethod(method.id),
}));
}
if (page.kind === "directory-search") {
return pathOptions.map((option) => {
const shortPath = shortenPath(option.path);
return {
id: option.path,
title: shortPath,
subtitle: directoryOptionSubtitle(option, shortPath),
icon: Folder,
testID: pathTestId(option.path),
select: () => void openAddedProject(option.path, "directory-search"),
};
});
}
if (page.kind === "github-search") {
const search = githubQuery.data?.query === page.query ? githubQuery.data.payload : null;
const repositories = search?.repositories ?? [];
const normalizedQuery = page.query.trim().toLowerCase();
const hasExactSearchResult = repositories.some(
(repository) =>
repository.nameWithOwner.toLowerCase() === normalizedQuery ||
repository.cloneUrl.toLowerCase() === normalizedQuery,
);
const manualRepositories = hasExactSearchResult
? []
: buildManualGithubRepositoryChoices(page.query);
const repositoryChoices: GithubRepositoryChoice[] = [...manualRepositories, ...repositories];
return repositoryChoices.map((repository) => ({
id: repository.id,
title: repository.cloneProtocol
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
: repository.nameWithOwner,
subtitle: repository.description,
icon: GitHubIcon,
testID: `add-project-flow-repository-${repository.id}`,
select: () =>
setState((current) => openGithubLocationPage(current, page.hostId, repository)),
}));
}
if (page.kind === "github-location") {
const repositoryName = pathBaseName(page.repository.nameWithOwner);
const lastParent = lastCloneParentByHost.get(page.hostId);
const parents = buildSuggestedParentDirectories(recommendedPaths);
const orderedParents = lastParent
? [lastParent, ...parents.filter((parent) => parent !== lastParent)]
: parents;
const filteredParents = buildProjectPickerOptions({
recommendedPaths: orderedParents,
serverPaths: directoryPaths,
query: page.query,
}).map((option) => option.path);
return buildCloneLocationOptions({
parents: filteredParents,
repositoryName,
existingPaths: [...recommendedPaths, ...directoryPaths],
}).map((option) => ({
id: option.id,
title: shortenPath(option.displayPath),
subtitle: option.secondaryText,
icon: HardDrive,
disabled: option.disabled,
testID: pathTestId(option.displayPath),
select: () => void cloneRepository(page, option.path),
}));
}
if (page.kind === "new-directory-parent") {
return pathOptions.map((option) => ({
id: option.path,
title: shortenPath(option.path),
subtitle: option.kind === "path" ? "Use this parent" : option.path,
icon: Folder,
testID: pathTestId(option.path),
select: () =>
setState((current) => openNewDirectoryNamePage(current, page.hostId, option.path)),
}));
}
return [];
}, [
cloneRepository,
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
pathOptions,
recommendedPaths,
selectMethod,
state.hosts,
]);
const activeIndex = rows.length === 0 ? 0 : Math.min(page.activeIndex, rows.length - 1);
const rowListKey = useMemo(() => rows.map((row) => row.id).join("\u0000"), [rows]);
const { activeItemRef, contentRef, onItemLayout, onViewportLayout, onScroll } =
useRevealActiveItem({
activeIndex,
listKey: rowListKey,
scrollRef: resultsRef,
});
const createDirectory = useCallback(async () => {
if (page.kind !== "new-directory-name" || !client) return;
const name = page.name.trim();
if (!name || name === "." || name === ".." || /[\\/]/.test(name)) {
setState((current) =>
setPageStatus(current, "new-directory-name", { error: "Enter a directory name" }),
);
return;
}
if (submissionInFlightRef.current) return;
submissionInFlightRef.current = true;
setState((current) =>
setPageStatus(current, "new-directory-name", { isSubmitting: true, error: null }),
);
try {
const payload = await client.createProjectDirectory({
parentPath: page.parentPath,
name,
});
if (payload.error || !payload.project) {
setState((current) =>
setPageStatus(current, "new-directory-name", {
isSubmitting: false,
error: payload.error ?? "Unable to create directory",
}),
);
return;
}
registerProjectDescriptor({
serverId: page.hostId,
project: payload.project,
upsertProject,
setHasHydratedWorkspaces,
});
openNewWorkspaceForProject(page.hostId, payload.project);
} catch {
setState((current) =>
setPageStatus(current, "new-directory-name", {
isSubmitting: false,
error: "Unable to create directory",
}),
);
} finally {
submissionInFlightRef.current = false;
}
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
void createDirectory();
return;
}
const option = rows[activeIndex];
if (option && !option.disabled) option.select();
}, [activeIndex, createDirectory, page.kind, rows]);
const handleKey = useCallback(
(key: string): boolean => {
if (key === "Escape") {
handleBack();
return true;
}
if (key === "Enter") {
submitActive();
return true;
}
if (key !== "ArrowDown" && key !== "ArrowUp") return false;
const next = moveAddProjectSelection(
activeIndex,
rows.map((row) => row.disabled !== true),
key === "ArrowDown" ? "next" : "previous",
);
setState((current) => setAddProjectActiveIndex(current, next));
return true;
},
[activeIndex, handleBack, rows, submitActive],
);
const modalLayer = useGlobalOverlayLayer("modal", true);
const handleOverlayKeyDown = useCallback(
(event: KeyboardInputEvent) => {
if (!handleKey(event.key)) return false;
event.preventDefault();
return true;
},
[handleKey],
);
const setOverlayScope = useOverlayRegistration({
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
active: true,
layer: modalLayer,
onKeyDown: handleOverlayKeyDown,
});
const handleInputChange = useCallback((value: string) => {
setState((current) =>
currentAddProjectPage(current).kind === "new-directory-name"
? setNewDirectoryName(current, value)
: setAddProjectPageInput(current, value),
);
}, []);
const isSubmitting = "isSubmitting" in page && page.isSubmitting;
const currentGithubSearch =
page.kind === "github-search" && githubQuery.data?.query === page.query
? githubQuery.data.payload
: null;
const loading =
(searchesDirectories && (query !== debouncedQuery || directoryQuery.isFetching)) ||
(page.kind === "github-search" &&
host?.canSearchGithubRepositories === true &&
(query !== debouncedQuery || githubQuery.isFetching));
const queryError = queryErrorText({
searchesDirectories,
directoryFailed: directoryQuery.isError,
githubFailed: page.kind === "github-search" && githubQuery.isError,
githubAvailable: currentGithubSearch?.available ?? null,
githubError: currentGithubSearch?.error ?? null,
});
const preview =
page.kind === "new-directory-name" && page.name.trim()
? joinDirectoryPath(page.parentPath, page.name.trim())
: null;
const modal = (
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
<View style={styles.overlay} testID="add-project-flow">
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
<View
ref={setOverlayScope}
style={styles.panel}
testID={`add-project-flow-page-${page.kind}`}
accessibilityLabel={`Add project: ${page.kind}`}
>
<View style={styles.header}>
<View style={styles.titleRow}>
{state.pages.length > 1 ? <FlowBackButton onPress={handleBack} /> : null}
<View style={styles.titleGroup} testID="add-project-flow-title">
<Text style={styles.title} numberOfLines={1}>
{pageTitle(page)}
</Text>
{host ? (
<Text style={styles.hostContext} numberOfLines={1}>
{host.label}
</Text>
) : null}
</View>
</View>
{page.kind !== "method" ? (
<ThemedTextInput
ref={inputRef}
initialValue={pageInput(page)}
onChangeText={handleInputChange}
onSubmitEditing={isWeb ? undefined : submitActive}
placeholder={pagePlaceholder(page)}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
testID="add-project-flow-input"
/>
) : null}
</View>
<ScrollView
ref={resultsRef}
style={styles.results}
keyboardShouldPersistTaps="always"
onLayout={onViewportLayout}
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
testID="add-project-flow-results"
>
<View ref={contentRef} collapsable={false} style={styles.resultsContent}>
{preview ? (
<Text style={styles.preview} testID="add-project-flow-path-preview">
{shortenPath(preview)}
</Text>
) : null}
{isSubmitting ? (
<Text style={styles.stateText} testID="add-project-flow-progress">
{progressText(page)}
</Text>
) : null}
{!isSubmitting && page.error ? (
<Text style={styles.errorText} testID="add-project-flow-error">
{page.error}
</Text>
) : null}
{!isSubmitting && queryError ? (
<Text style={styles.errorText} testID="add-project-flow-query-error">
{queryError}
</Text>
) : null}
{!isSubmitting && loading ? (
<Text style={styles.stateText} testID="add-project-flow-loading">
Loading...
</Text>
) : null}
{!isSubmitting &&
(!loading || page.kind === "github-search") &&
(!queryError || page.kind === "github-search")
? rows.map((option, index) => (
<FlowRow
key={option.id}
option={option}
activeItemRef={activeItemRef}
active={index === activeIndex}
onRowLayout={onItemLayout}
/>
))
: null}
{!isSubmitting &&
!loading &&
!queryError &&
rows.length === 0 &&
page.kind !== "new-directory-name" ? (
<Text style={styles.stateText} testID="add-project-flow-empty">
{emptyText(page, host ?? null)}
</Text>
) : null}
</View>
</ScrollView>
<View style={styles.footer} testID="add-project-flow-footer">
<FlowHint keys={NAVIGATION_HINT_KEYS} action="Navigate" />
<FlowHint keys={SELECT_HINT_KEYS} action="Select" />
<FlowHint keys={ESCAPE_HINT_KEYS} action={state.pages.length > 1 ? "Back" : "Close"} />
</View>
</View>
</View>
</Modal>
);
return createElement(OverlayLayerProvider, { layer: modalLayer }, modal);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
},
backdrop: {
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
width: 640,
maxWidth: "92%",
maxHeight: "80%",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
...theme.shadow.lg,
},
header: {
flexShrink: 0,
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
gap: theme.spacing[2],
},
titleRow: {
minHeight: theme.iconSize.lg,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
backButton: {
width: 18,
height: theme.iconSize.lg,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
titleGroup: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "baseline",
gap: theme.spacing[2],
},
title: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
hostContext: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
resultsContent: { paddingVertical: theme.spacing[2] },
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
rowActive: { backgroundColor: theme.colors.surface1 },
disabled: { opacity: theme.opacity[50] },
iconSlot: { width: 18, alignItems: "center" },
rowText: { flex: 1, minWidth: 0 },
rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
rowSubtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, marginTop: 2 },
preview: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
stateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
},
errorText: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
footer: {
flexShrink: 0,
flexDirection: "row",
gap: theme.spacing[4],
alignItems: "center",
flexWrap: "wrap",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHint: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
footerKeyText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
footerAction: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));