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
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
backAddProjectPage,
chooseAddProjectHost,
currentAddProjectPage,
moveAddProjectActiveIndex,
moveAddProjectSelection,
openAddProjectFlow,
openDirectorySearchPage,
openGithubLocationPage,
openNewDirectoryNamePage,
openNewDirectoryParentPage,
setAddProjectActiveIndex,
setAddProjectPageInput,
setNewDirectoryName,
type AddProjectHost,
} from "./model";
import {
buildAddProjectMethods,
buildCloneLocationOptions,
buildManualGithubRepositoryChoices,
} from "./options";
const HOST: AddProjectHost = {
serverId: "host-1",
label: "Local",
canAddProject: true,
canBrowse: true,
canCloneGithubRepositories: true,
canSearchGithubRepositories: true,
canCreateDirectory: true,
};
describe("Add Project navigation", () => {
it("skips a single connected host without adding it to history", () => {
const state = openAddProjectFlow({ hosts: [HOST] });
expect(currentAddProjectPage(state)).toEqual({
kind: "method",
hostId: "host-1",
query: "",
activeIndex: 0,
error: null,
});
expect(backAddProjectPage(state)).toBeNull();
});
it("restores page input and selection after Back", () => {
const secondHost = { ...HOST, serverId: "host-2", label: "Remote" };
let state = openAddProjectFlow({ hosts: [HOST, secondHost] });
state = setAddProjectPageInput(state, "rem");
state = setAddProjectActiveIndex(state, 1);
state = chooseAddProjectHost(state, secondHost.serverId);
state = openDirectorySearchPage(state, secondHost.serverId);
state = backAddProjectPage(state) ?? state;
state = backAddProjectPage(state) ?? state;
expect(currentAddProjectPage(state)).toEqual({
kind: "host",
query: "rem",
activeIndex: 1,
error: null,
});
});
it("wraps keyboard selection in both directions", () => {
expect(moveAddProjectActiveIndex(2, 3, "next")).toBe(0);
expect(moveAddProjectActiveIndex(0, 3, "previous")).toBe(2);
expect(moveAddProjectSelection(0, [true, false, true], "next")).toBe(2);
});
it("restores a directory name after returning to and reselecting its parent", () => {
let state = openAddProjectFlow({ hosts: [HOST] });
state = openNewDirectoryParentPage(state, HOST.serverId);
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
state = setNewDirectoryName(state, "command-center");
state = backAddProjectPage(state) ?? state;
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
expect(currentAddProjectPage(state)).toMatchObject({
kind: "new-directory-name",
parentPath: "~/dev",
name: "command-center",
});
});
it("restores the GitHub destination query and active parent when reopening a repository", () => {
const repository = {
id: "repo-1",
nameWithOwner: "getpaseo/paseo",
cloneUrl: "git@github.com:getpaseo/paseo.git",
description: null,
visibility: "public",
updatedAt: null,
};
let state = openAddProjectFlow({ hosts: [HOST] });
state = openGithubLocationPage(state, HOST.serverId, repository);
state = setAddProjectPageInput(state, "~/dev");
state = setAddProjectActiveIndex(state, 2);
state = backAddProjectPage(state) ?? state;
state = openGithubLocationPage(state, HOST.serverId, repository);
expect(currentAddProjectPage(state)).toMatchObject({
kind: "github-location",
query: "~/dev",
activeIndex: 2,
});
});
});
describe("Add Project options", () => {
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
expect(
buildAddProjectMethods({
...HOST,
canBrowse: false,
canCloneGithubRepositories: false,
canSearchGithubRepositories: false,
canCreateDirectory: false,
}),
).toEqual([
{
id: "directory-search",
label: "Search for directory",
description: "Find a directory on Local",
},
{
id: "github",
label: "Clone from GitHub",
description: "Update this host to clone GitHub repositories",
disabled: true,
},
{
id: "new-directory",
label: "New directory",
description: "Update this host to create directories",
disabled: true,
},
]);
});
it("offers manual URL and protocol-specific owner/repo clone choices", () => {
expect(buildManualGithubRepositoryChoices("git@github.com:getpaseo/paseo.git")).toEqual([
expect.objectContaining({
id: "manual:git@github.com:getpaseo/paseo.git",
nameWithOwner: "getpaseo/paseo",
cloneUrl: "git@github.com:getpaseo/paseo.git",
}),
]);
expect(buildManualGithubRepositoryChoices("getpaseo/paseo")).toEqual([
expect.objectContaining({ cloneProtocol: "https", cloneUrl: "getpaseo/paseo" }),
expect.objectContaining({ cloneProtocol: "ssh", cloneUrl: "getpaseo/paseo" }),
]);
expect(buildManualGithubRepositoryChoices("paseo")).toEqual([]);
});
it("shows final clone paths while retaining parent paths as values", () => {
expect(
buildCloneLocationOptions({
parents: ["~/dev", "~/workspace"],
repositoryName: "paseo",
existingPaths: ["~/workspace/paseo"],
}),
).toEqual([
{
id: "~/dev",
path: "~/dev",
displayPath: "~/dev/paseo",
secondaryText: "Parent directory: ~/dev",
disabled: false,
},
{
id: "~/workspace",
path: "~/workspace",
displayPath: "~/workspace/paseo",
secondaryText: "Already exists",
disabled: true,
},
]);
});
});
+293
View File
@@ -0,0 +1,293 @@
export interface AddProjectHost {
serverId: string;
label: string;
canAddProject: boolean;
canBrowse: boolean;
canCloneGithubRepositories: boolean;
canSearchGithubRepositories: boolean;
canCreateDirectory: boolean;
}
export interface GithubRepositoryChoice {
id: string;
nameWithOwner: string;
cloneUrl: string;
cloneProtocol?: "https" | "ssh";
description: string | null;
visibility: string | null;
updatedAt: string | null;
}
interface SearchPageState {
query: string;
activeIndex: number;
error: string | null;
}
export type AddProjectPage =
| ({ kind: "host" } & SearchPageState)
| ({ kind: "method"; hostId: string } & SearchPageState)
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
| ({ kind: "github-search"; hostId: string } & SearchPageState)
| ({
kind: "github-location";
hostId: string;
repository: GithubRepositoryChoice;
isSubmitting: boolean;
} & SearchPageState)
| ({ kind: "new-directory-parent"; hostId: string } & SearchPageState)
| {
kind: "new-directory-name";
hostId: string;
parentPath: string;
name: string;
activeIndex: number;
error: string | null;
isSubmitting: boolean;
};
export interface AddProjectFlowState {
hosts: AddProjectHost[];
pages: AddProjectPage[];
newDirectoryNameDrafts: Record<string, string>;
githubLocationDrafts: Record<string, { query: string; activeIndex: number }>;
}
export interface OpenAddProjectFlowInput {
hosts: AddProjectHost[];
preferredHostId?: string;
}
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
return { kind, query: "", activeIndex: 0, error: null } as const;
}
function methodPage(hostId: string): AddProjectPage {
return { ...searchPage("method"), hostId };
}
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
const preferredHost = input.preferredHostId
? input.hosts.find((host) => host.serverId === input.preferredHostId)
: null;
const onlyHost = input.hosts.length === 1 ? input.hosts[0] : null;
const initialHost = preferredHost ?? onlyHost;
return {
hosts: input.hosts,
pages: initialHost ? [methodPage(initialHost.serverId)] : [searchPage("host")],
newDirectoryNameDrafts: {},
githubLocationDrafts: {},
};
}
export function applyAvailableAddProjectHosts(
state: AddProjectFlowState,
hosts: AddProjectHost[],
preferredHostId?: string,
): AddProjectFlowState {
const current = currentAddProjectPage(state);
if (state.pages.length !== 1 || current.kind !== "host") {
return { ...state, hosts };
}
const preferredHost = preferredHostId
? hosts.find((host) => host.serverId === preferredHostId)
: null;
const onlyHost = hosts.length === 1 ? hosts[0] : null;
const initialHost = preferredHost ?? onlyHost;
return {
...state,
hosts,
pages: initialHost ? [methodPage(initialHost.serverId)] : state.pages,
};
}
export function currentAddProjectPage(state: AddProjectFlowState): AddProjectPage {
const page = state.pages[state.pages.length - 1];
if (!page) {
throw new Error("Add Project flow must always contain a page");
}
return page;
}
export function updateCurrentAddProjectPage(
state: AddProjectFlowState,
update: (page: AddProjectPage) => AddProjectPage,
): AddProjectFlowState {
const index = state.pages.length - 1;
return {
...state,
pages: state.pages.map((page, pageIndex) => (pageIndex === index ? update(page) : page)),
};
}
export function pushAddProjectPage(
state: AddProjectFlowState,
page: AddProjectPage,
): AddProjectFlowState {
return { ...state, pages: [...state.pages, page] };
}
export function backAddProjectPage(state: AddProjectFlowState): AddProjectFlowState | null {
if (state.pages.length === 1) {
return null;
}
return { ...state, pages: state.pages.slice(0, -1) };
}
export function chooseAddProjectHost(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, methodPage(hostId));
}
export function openDirectorySearchPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, {
...searchPage("directory-search"),
hostId,
isSubmitting: false,
});
}
export function openGithubSearchPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, { ...searchPage("github-search"), hostId });
}
export function openGithubLocationPage(
state: AddProjectFlowState,
hostId: string,
repository: GithubRepositoryChoice,
): AddProjectFlowState {
const draft = state.githubLocationDrafts[githubLocationDraftKey(hostId, repository.id)];
return pushAddProjectPage(state, {
kind: "github-location",
query: draft?.query ?? "",
activeIndex: draft?.activeIndex ?? 0,
error: null,
hostId,
repository,
isSubmitting: false,
});
}
export function openNewDirectoryParentPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, { ...searchPage("new-directory-parent"), hostId });
}
export function openNewDirectoryNamePage(
state: AddProjectFlowState,
hostId: string,
parentPath: string,
): AddProjectFlowState {
const draftKey = newDirectoryDraftKey(hostId, parentPath);
return pushAddProjectPage(state, {
kind: "new-directory-name",
hostId,
parentPath,
name: state.newDirectoryNameDrafts[draftKey] ?? "",
activeIndex: 0,
error: null,
isSubmitting: false,
});
}
export function setAddProjectPageInput(
state: AddProjectFlowState,
value: string,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
const updated = updateCurrentAddProjectPage(state, (current) => {
if (current.kind === "new-directory-name") {
return { ...current, name: value, activeIndex: 0, error: null };
}
return { ...current, query: value, activeIndex: 0, error: null };
});
if (page.kind !== "github-location") return updated;
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
return {
...updated,
githubLocationDrafts: {
...updated.githubLocationDrafts,
[draftKey]: { query: value, activeIndex: 0 },
},
};
}
export function setNewDirectoryName(
state: AddProjectFlowState,
value: string,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
if (page.kind !== "new-directory-name") return state;
const draftKey = newDirectoryDraftKey(page.hostId, page.parentPath);
const updated = setAddProjectPageInput(state, value);
return {
...updated,
newDirectoryNameDrafts: {
...updated.newDirectoryNameDrafts,
[draftKey]: value,
},
};
}
function newDirectoryDraftKey(hostId: string, parentPath: string): string {
return `${hostId}\u0000${parentPath}`;
}
function githubLocationDraftKey(hostId: string, repositoryId: string): string {
return `${hostId}\u0000${repositoryId}`;
}
export function setAddProjectActiveIndex(
state: AddProjectFlowState,
activeIndex: number,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
const updated = updateCurrentAddProjectPage(state, (current) => ({ ...current, activeIndex }));
if (page.kind !== "github-location") return updated;
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
return {
...updated,
githubLocationDrafts: {
...updated.githubLocationDrafts,
[draftKey]: { query: page.query, activeIndex },
},
};
}
export function moveAddProjectActiveIndex(
activeIndex: number,
optionCount: number,
direction: "next" | "previous",
): number {
if (optionCount === 0) return 0;
const delta = direction === "next" ? 1 : -1;
const next = activeIndex + delta;
if (next < 0) return optionCount - 1;
if (next >= optionCount) return 0;
return next;
}
export function moveAddProjectSelection(
activeIndex: number,
selectable: readonly boolean[],
direction: "next" | "previous",
): number {
if (!selectable.some(Boolean)) return 0;
let next = activeIndex;
for (let count = 0; count < selectable.length; count += 1) {
next = moveAddProjectActiveIndex(next, selectable.length, direction);
if (selectable[next]) return next;
}
return activeIndex;
}
@@ -0,0 +1,164 @@
import {
isCompleteGitRemote,
parseGitHubRemoteUrl,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import type { AddProjectHost, GithubRepositoryChoice } from "./model";
export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory";
export interface AddProjectMethodOption {
id: AddProjectMethodId;
label: string;
description: string;
disabled?: boolean;
}
export interface AddProjectPathOption {
id: string;
path: string;
displayPath: string;
secondaryText: string | null;
disabled: boolean;
}
export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): AddProjectHost[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return hosts;
return hosts.filter(
(host) =>
host.label.toLowerCase().includes(normalized) ||
host.serverId.toLowerCase().includes(normalized),
);
}
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
const options: AddProjectMethodOption[] = [];
if (host.canAddProject) {
options.push({
id: "directory-search",
label: "Search for directory",
description: `Find a directory on ${host.label}`,
});
}
if (host.canBrowse) {
options.push({
id: "browse",
label: "Browse",
description: "Choose or create a directory in Finder",
});
}
options.push({
id: "github",
label: "Clone from GitHub",
description: githubMethodDescription(host),
disabled: !host.canCloneGithubRepositories,
});
options.push({
id: "new-directory",
label: "New directory",
description: host.canCreateDirectory
? `Create an empty directory on ${host.label}`
: "Update this host to create directories",
disabled: !host.canCreateDirectory,
});
return options;
}
function githubMethodDescription(host: AddProjectHost): string {
if (!host.canCloneGithubRepositories) {
return "Update this host to clone GitHub repositories";
}
if (host.canSearchGithubRepositories) {
return "Search projects available to your GitHub account";
}
return "Enter a GitHub URL or owner/repo";
}
export function pathBaseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, "");
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] ?? trimmed;
}
export function buildManualGithubRepositoryChoices(query: string): GithubRepositoryChoice[] {
const repo = query.trim();
if (!repo) return [];
if (isCompleteGitRemote(repo)) {
const identity = parseGitHubRemoteUrl(repo);
const location = parseGitRemoteLocation(repo);
const remoteName = location ? pathBaseName(location.path).replace(/\.git$/u, "") : repo;
return [
{
id: `manual:${repo}`,
nameWithOwner: identity?.repo ?? remoteName,
cloneUrl: repo,
description: "Clone this repository URL",
visibility: null,
updatedAt: null,
},
];
}
const shorthand = repo.match(/^([^\s/]+)\/([^\s/]+)$/u);
if (!shorthand) return [];
const nameWithOwner = `${shorthand[1]}/${shorthand[2]}`;
return (["https", "ssh"] as const).map((cloneProtocol) => ({
id: `manual:${cloneProtocol}:${nameWithOwner}`,
nameWithOwner,
cloneUrl: nameWithOwner,
cloneProtocol,
description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`,
visibility: null,
updatedAt: null,
}));
}
export function parentDirectory(path: string): string | null {
const trimmed = path.replace(/[\\/]+$/, "");
const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
if (index < 0) return null;
if (index === 0) return trimmed.slice(0, 1);
return trimmed.slice(0, index);
}
export function joinDirectoryPath(parent: string, name: string): string {
const trimmedParent = parent.replace(/[\\/]+$/, "");
const separator = trimmedParent.includes("\\") && !trimmedParent.includes("/") ? "\\" : "/";
return `${trimmedParent}${separator}${name}`;
}
export function buildSuggestedParentDirectories(projectPaths: string[]): string[] {
const values = [
...projectPaths.flatMap((path) => {
const parent = parentDirectory(path);
return parent ? [parent] : [];
}),
"~/dev",
"~/Developer",
"~/src",
"~/projects",
"~/workspace",
"~",
];
return [...new Set(values)];
}
export function buildCloneLocationOptions(input: {
parents: string[];
repositoryName: string;
existingPaths: string[];
}): AddProjectPathOption[] {
const existing = new Set(input.existingPaths);
return input.parents.map((parent) => {
const path = joinDirectoryPath(parent, input.repositoryName);
return {
id: parent,
path: parent,
displayPath: path,
secondaryText: existing.has(path) ? "Already exists" : `Parent directory: ${parent}`,
disabled: existing.has(path),
};
});
}
-2
View File
@@ -31,7 +31,6 @@ import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -550,7 +549,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
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",
},
}));
+1
View File
@@ -24,6 +24,7 @@ export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
createDirectory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
@@ -19,6 +19,7 @@ describe("pickDirectory", () => {
{
directory: true,
multiple: false,
createDirectory: true,
},
]);
});
@@ -11,6 +11,7 @@ export async function pickDirectory(
const selection = await open({
directory: true,
multiple: false,
createDirectory: true,
});
if (selection === null) {
return null;
+26 -6
View File
@@ -1,5 +1,9 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProjectAddResponse, WorkspaceGithubCloneProtocol } from "@getpaseo/protocol/messages";
import type {
ProjectAddResponse,
WorkspaceGithubCloneProtocol,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
normalizeWorkspaceDescriptor,
@@ -62,6 +66,21 @@ interface WorkspaceOpenCallbacks {
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks {
repo: string;
targetDirectory: string;
@@ -95,11 +114,12 @@ export async function openProjectDirectly(
};
}
input.addEmptyProject(
normalizedServerId,
normalizeProjectWithoutWorkspacesDescriptor(payload.project),
);
input.setHasHydratedWorkspaces(normalizedServerId, true);
registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return { ok: true };
}
+16 -14
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
@@ -59,7 +59,7 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
titleKey:
| "shell.commandCenter.openProject"
| "shell.commandCenter.addProject"
| "shell.commandCenter.home"
| "sidebar.actions.settings";
icon?: "plus" | "settings" | "home";
@@ -71,7 +71,7 @@ interface CommandCenterActionDefinition {
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
{
id: "new-agent",
titleKey: "shell.commandCenter.openProject",
titleKey: "shell.commandCenter.addProject",
icon: "plus",
actionId: "new-agent",
keywords: ["open", "project", "folder", "workspace", "repo"],
@@ -146,6 +146,9 @@ export function useCommandCenter() {
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const addProjectRequest = useAddProjectFlowStore((state) => state.request);
const openAddProjectFlow = useAddProjectFlowStore((state) => state.open);
const closeAddProjectFlow = useAddProjectFlowStore((state) => state.close);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -318,23 +321,21 @@ export function useCommandCenter() {
[setOpen],
);
const openProjectPicker = useOpenProjectPicker();
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
void openProjectPicker();
openAddProjectFlow();
return;
}
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
},
[openProjectPicker, setOpen],
[openAddProjectFlow, setOpen],
);
const handleSelectItem = useCallback(
@@ -373,6 +374,7 @@ export function useCommandCenter() {
prevOpenRef.current = open;
if (!open) {
closeAddProjectFlow();
setQuery("");
setActiveIndex(0);
@@ -403,7 +405,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [open]);
}, [closeAddProjectFlow, open]);
useEffect(() => {
if (!open) return;
@@ -414,7 +416,7 @@ export function useCommandCenter() {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open) return false;
if (!open || addProjectRequest) return false;
const currentItems = itemsRef.current;
if (key === "Escape") {
@@ -443,11 +445,11 @@ export function useCommandCenter() {
return false;
},
[open],
[addProjectRequest, open],
);
useEffect(() => {
if (!open || !isWeb) return;
if (!open || addProjectRequest || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
@@ -466,7 +468,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [open, handleKeyEvent]);
}, [addProjectRequest, open, handleKeyEvent]);
return {
open,
@@ -18,7 +18,7 @@ import {
type ShortcutCallbackName,
} from "@/keyboard/route-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
@@ -54,7 +54,7 @@ export function useKeyboardShortcuts({
step: 0,
timeoutId: null,
});
const openProjectPickerAction = useOpenProjectPicker();
const openProjectPickerAction = useOpenAddProject();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
@@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
export function useOpenAddProject(): (preferredHostId?: string) => void {
const openFlow = useAddProjectFlowStore((state) => state.open);
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
return useCallback(
(preferredHostId?: string) => {
openFlow(preferredHostId);
setCommandCenterOpen(true);
},
[openFlow, setCommandCenterOpen],
);
}
@@ -1,15 +0,0 @@
import { useCallback } from "react";
import { useHostChooser } from "@/hosts/host-chooser";
import { useProjectPickerStore } from "@/stores/project-picker-store";
export function useOpenProjectPicker(): () => void {
const chooseHost = useHostChooser();
const openProjectPicker = useProjectPickerStore((state) => state.open);
return useCallback(() => {
chooseHost({
title: "Choose host",
onChooseHost: openProjectPicker,
});
}, [chooseHost, openProjectPicker]);
}
+1 -1
View File
@@ -182,7 +182,7 @@ describe("translation resources", () => {
expect(en.shell.commandCenter.workspaces).toBe("Workspaces");
expect(en.shell.commandCenter.agents).toBe("Agents");
expect(en.shell.commandCenter.newAgent).toBe("New agent");
expect(en.shell.commandCenter.openProject).toBe("Open project");
expect(en.shell.commandCenter.addProject).toBe("Add project");
expect(en.shell.commandCenter.home).toBe("Home");
});
+1 -1
View File
@@ -60,7 +60,7 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
openProject: "مشروع مفتوح",
addProject: "إضافة مشروع",
home: "بيت",
},
},
+1 -1
View File
@@ -58,7 +58,7 @@ export const en = {
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
openProject: "Open project",
addProject: "Add project",
home: "Home",
},
},
+1 -1
View File
@@ -60,7 +60,7 @@ export const es: TranslationResources = {
workspaces: "Espacios de trabajo",
agents: "Agentes",
newAgent: "Nuevo agente",
openProject: "Abrir proyecto",
addProject: "Agregar proyecto",
home: "Hogar",
},
},
+1 -1
View File
@@ -61,7 +61,7 @@ export const fr: TranslationResources = {
workspaces: "Espaces de travail",
agents: "Agents",
newAgent: "Nouvel agent",
openProject: "Projet ouvert",
addProject: "Ajouter un projet",
home: "Maison",
},
},
+1 -1
View File
@@ -60,7 +60,7 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
openProject: "プロジェクトを開く",
addProject: "プロジェクトを追加",
home: "ホーム",
},
},
+1 -1
View File
@@ -60,7 +60,7 @@ export const ptBR: TranslationResources = {
workspaces: "Espaços de trabalho",
agents: "Agentes",
newAgent: "Novo agente",
openProject: "Abrir projeto",
addProject: "Adicionar projeto",
home: "Início",
},
},
+1 -1
View File
@@ -60,7 +60,7 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
openProject: "Открыть проект",
addProject: "Добавить проект",
home: "Дом",
},
},
+1 -1
View File
@@ -60,7 +60,7 @@ export const zhCN: TranslationResources = {
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
openProject: "打开项目",
addProject: "添加 project",
home: "首页",
},
},
@@ -54,7 +54,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
useWorkspaceDraftSubmissionStore,
@@ -1624,7 +1624,7 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useProjectPickerStore((state) => state.open);
const openAddProjectPicker = useOpenAddProject();
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
@@ -7,7 +7,7 @@ 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 { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useHostChooser } from "@/hosts/host-chooser";
import { usePanelStore } from "@/stores/panel-store";
import {
@@ -29,7 +29,7 @@ export function OpenProjectScreen() {
const { t } = useTranslation();
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker();
const openProjectPicker = useOpenAddProject();
const chooseHost = useHostChooser();
const localServerId = useLocalDaemonServerId();
const [importServerId, setImportServerId] = useState<string | null>(null);
@@ -0,0 +1,27 @@
import { create } from "zustand";
export interface AddProjectFlowRequest {
id: number;
preferredHostId?: string;
}
interface AddProjectFlowStoreState {
request: AddProjectFlowRequest | null;
open: (preferredHostId?: string) => void;
close: () => void;
}
let nextRequestId = 1;
export const useAddProjectFlowStore = create<AddProjectFlowStoreState>((set) => ({
request: null,
open: (preferredHostId) => {
set({
request: {
id: nextRequestId++,
...(preferredHostId ? { preferredHostId } : {}),
},
});
},
close: () => set({ request: null }),
}));
@@ -1,17 +0,0 @@
import { create } from "zustand";
interface ProjectPickerRequest {
serverId: string;
}
interface ProjectPickerStoreState {
request: ProjectPickerRequest | null;
open: (serverId: string) => void;
close: () => void;
}
export const useProjectPickerStore = create<ProjectPickerStoreState>((set) => ({
request: null,
open: (serverId) => set({ request: { serverId } }),
close: () => set({ request: null }),
}));