fix(projects): separate registration from workspace setup

Adding or cloning a project now registers it and opens workspace setup
instead of creating a workspace implicitly. Keep Add Project independently
mounted from Search so closing one cannot control the other.
This commit is contained in:
Mohamed Boudra
2026-07-15 23:13:55 +02:00
parent 38cfe109c9
commit 943d03ad99
23 changed files with 340 additions and 295 deletions
@@ -179,4 +179,22 @@ describe("Add Project options", () => {
},
]);
});
it("shows equivalent absolute-home and tilde destinations only once", () => {
expect(
buildCloneLocationOptions({
parents: ["/Users/moboudra/dev", "~/dev"],
repositoryName: "dotfiles",
existingPaths: [],
}),
).toEqual([
{
id: "/Users/moboudra/dev",
path: "/Users/moboudra/dev",
displayPath: "/Users/moboudra/dev/dotfiles",
secondaryText: "Parent directory: /Users/moboudra/dev",
disabled: false,
},
]);
});
});
+24 -9
View File
@@ -3,6 +3,7 @@ import {
parseGitHubRemoteUrl,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import { shortenPath } from "@/utils/shorten-path";
import type { AddProjectHost, GithubRepositoryChoice } from "./model";
export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory";
@@ -150,15 +151,29 @@ export function buildCloneLocationOptions(input: {
repositoryName: string;
existingPaths: string[];
}): AddProjectPathOption[] {
const existing = new Set(input.existingPaths);
return input.parents.map((parent) => {
const existing = new Set(input.existingPaths.map(pathIdentity));
const seen = new Set<string>();
return input.parents.flatMap((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),
};
const identity = pathIdentity(path);
if (seen.has(identity)) return [];
seen.add(identity);
const pathExists = existing.has(identity);
return [
{
id: parent,
path: parent,
displayPath: path,
secondaryText: pathExists ? "Already exists" : `Parent directory: ${parent}`,
disabled: pathExists,
},
];
});
}
function pathIdentity(path: string): string {
const normalized = shortenPath(path.trim()).replace(/\\/g, "/").replace(/\/+$/u, "");
return /^[A-Za-z]:\//u.test(normalized) || normalized.startsWith("//")
? normalized.toLowerCase()
: normalized;
}
+2
View File
@@ -22,6 +22,7 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
@@ -548,6 +549,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
@@ -0,0 +1,11 @@
import { AddProjectFlow } from "@/components/add-project-flow";
import { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
export function AddProjectFlowHost() {
const request = useAddProjectFlowStore((state) => state.request);
const close = useAddProjectFlowStore((state) => state.close);
if (!request) return null;
return <AddProjectFlow key={request.id} request={request} onClose={close} />;
}
@@ -1,4 +1,5 @@
import { router } from "expo-router";
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
import {
ArrowLeft,
Folder,
@@ -63,7 +64,7 @@ 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 { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project";
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
import {
useHosts,
useHostRuntimeClient,
@@ -75,7 +76,7 @@ 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 { buildSettingsAddHostRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -197,7 +198,7 @@ function pageTitle(page: AddProjectPage): string {
case "github-search":
return "Clone from GitHub";
case "github-location":
return `Where should Paseo create ${pathBaseName(page.repository.nameWithOwner)}?`;
return "Choose destination";
case "new-directory-parent":
return "Choose parent directory";
case "new-directory-name":
@@ -296,8 +297,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-15.
const githubCloneByHost = useHostFeatureMap(hostIds, "workspaceGithubClone");
// 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.
@@ -343,7 +344,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const isLocalDaemon = useIsLocalDaemon(hostId ?? "");
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const openGithubRepo = useOpenGithubRepo(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null);
@@ -416,9 +417,24 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
});
}, [onClose]);
const openNewWorkspaceForProject = useCallback(
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
onClose();
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 (submissionInFlightRef.current) return;
if (!hostId || submissionInFlightRef.current) return;
submissionInFlightRef.current = true;
setState((current) =>
setPageStatus(current, sourceKind, { isSubmitting: true, error: null }),
@@ -426,7 +442,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
try {
const result = await openProject(path);
if (result.ok) {
onClose();
openNewWorkspaceForProject(hostId, result.project);
return;
}
const reason = getOpenProjectFailureReason(result);
@@ -446,7 +462,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
submissionInFlightRef.current = false;
}
},
[onClose, openProject],
[hostId, openNewWorkspaceForProject, openProject],
);
const browse = useCallback(async () => {
@@ -501,34 +517,34 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
setPageStatus(current, "github-location", { isSubmitting: true, error: null }),
);
try {
const opened = await openGithubRepo(
const result = await cloneGithubProject(
locationPage.repository.cloneUrl,
parentPath,
locationPage.repository.cloneProtocol,
);
if (opened) {
if (result.ok) {
lastCloneParentByHost.set(locationPage.hostId, parentPath);
onClose();
openNewWorkspaceForProject(locationPage.hostId, result.project);
return;
}
setState((current) =>
setPageStatus(current, "github-location", {
isSubmitting: false,
error: "Unable to clone repository",
error: result.error ?? "Unable to clone repository",
}),
);
} catch {
} catch (error) {
setState((current) =>
setPageStatus(current, "github-location", {
isSubmitting: false,
error: "Unable to clone repository",
error: error instanceof Error ? error.message : "Unable to clone repository",
}),
);
} finally {
submissionInFlightRef.current = false;
}
},
[onClose, openGithubRepo],
[cloneGithubProject, openNewWorkspaceForProject],
);
const rows = useMemo<FlowRowOption[]>(() => {
if (page.kind === "host") {
@@ -702,7 +718,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
addEmptyProject,
setHasHydratedWorkspaces,
});
onClose();
openNewWorkspaceForProject(page.hostId, payload.project);
} catch {
setState((current) =>
setPageStatus(current, "new-directory-name", {
@@ -713,7 +729,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally {
submissionInFlightRef.current = false;
}
}, [addEmptyProject, client, onClose, page, setHasHydratedWorkspaces]);
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
@@ -21,8 +21,6 @@ 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,
@@ -338,8 +336,6 @@ export function CommandCenter() {
handleSelectItem,
handleKeyEvent,
} = useCommandCenter();
const addProjectRequest = useAddProjectFlowStore((state) => state.request);
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const rowRefs = useRef<Map<number, View>>(new Map());
@@ -493,16 +489,6 @@ 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>
+30 -50
View File
@@ -1,27 +1,20 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
ProjectGithubCloneProtocol,
ProjectAddResponse,
WorkspaceGithubCloneProtocol,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
normalizeWorkspaceDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { generateDraftId } from "@/stores/draft-keys";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
type WorkspaceOpenPayload =
| Awaited<ReturnType<DaemonClient["openProject"]>>
| Awaited<ReturnType<DaemonClient["cloneGithubWorkspace"]>>;
export interface OpenProjectSuccess {
ok: true;
project: WorkspaceProjectDescriptorPayload;
}
export interface OpenProjectFailure {
@@ -32,7 +25,7 @@ export interface OpenProjectFailure {
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
export type { WorkspaceGithubCloneProtocol };
export type { ProjectGithubCloneProtocol };
export function getOpenProjectFailureReason(
result: OpenProjectResult,
@@ -58,12 +51,11 @@ export interface OpenProjectDirectlyInput {
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
interface WorkspaceOpenCallbacks {
interface ProjectRegistrationCallbacks {
serverId: string;
isConnected: boolean;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
}
export interface RegisterProjectDescriptorInput {
@@ -81,11 +73,11 @@ export function registerProjectDescriptor(input: RegisterProjectDescriptorInput)
return true;
}
export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks {
export interface CloneGithubProjectDirectlyInput extends ProjectRegistrationCallbacks {
repo: string;
targetDirectory: string;
cloneProtocol?: WorkspaceGithubCloneProtocol;
client: Pick<DaemonClient, "cloneGithubWorkspace"> | null;
cloneProtocol?: ProjectGithubCloneProtocol;
client: Pick<DaemonClient, "cloneGithubProject"> | null;
}
export async function openProjectDirectly(
@@ -114,44 +106,20 @@ export async function openProjectDirectly(
};
}
registerProjectDescriptor({
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return { ok: true };
return registered
? { ok: true, project: payload.project }
: { ok: false, errorCode: null, error: "Unable to register project" };
}
function finishWorkspaceOpen(
input: WorkspaceOpenCallbacks,
payload: WorkspaceOpenPayload,
): boolean {
const normalizedServerId = input.serverId.trim();
if (!normalizedServerId || payload.error || !payload.workspace) {
return false;
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: workspace.id,
});
if (!workspaceKey) {
return false;
}
input.mergeWorkspaces(normalizedServerId, [workspace]);
input.setHasHydratedWorkspaces(normalizedServerId, true);
input.navigateToWorkspace({
serverId: normalizedServerId,
workspaceId: workspace.id,
target: { kind: "draft", draftId: generateDraftId() },
});
return true;
}
export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput): Promise<boolean> {
export async function cloneGithubProjectDirectly(
input: CloneGithubProjectDirectlyInput,
): Promise<OpenProjectResult> {
const normalizedServerId = input.serverId.trim();
const trimmedRepo = input.repo.trim();
const trimmedTargetDirectory = input.targetDirectory.trim();
@@ -162,13 +130,25 @@ export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput)
!input.client ||
!input.isConnected
) {
return false;
return { ok: false, errorCode: null, error: null };
}
const payload = await input.client.cloneGithubWorkspace({
const payload = await input.client.cloneGithubProject({
repo: trimmedRepo,
targetDirectory: trimmedTargetDirectory,
...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}),
});
return finishWorkspaceOpen(input, payload);
if (payload.error || !payload.project) {
return { ok: false, errorCode: null, error: payload.error };
}
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
? { ok: true, project: payload.project }
: { ok: false, errorCode: null, error: "Unable to register project" };
}
+12 -15
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 { useAddProjectFlowStore } from "@/stores/add-project-flow-store";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
@@ -146,9 +146,7 @@ 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 openAddProject = useOpenAddProject();
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -323,19 +321,19 @@ export function useCommandCenter() {
const handleSelectAction = useCallback(
(action: CommandCenterActionItem) => {
if (action.id === "new-agent") {
openAddProjectFlow();
return;
}
clearCommandCenterFocusRestoreElement();
setOpen(false);
if (action.id === "new-agent") {
openAddProject();
return;
}
if (!action.route) {
return;
}
didNavigateRef.current = true;
router.push(action.route);
},
[openAddProjectFlow, setOpen],
[openAddProject, setOpen],
);
const handleSelectItem = useCallback(
@@ -374,7 +372,6 @@ export function useCommandCenter() {
prevOpenRef.current = open;
if (!open) {
closeAddProjectFlow();
setQuery("");
setActiveIndex(0);
@@ -405,7 +402,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [closeAddProjectFlow, open]);
}, [open]);
useEffect(() => {
if (!open) return;
@@ -416,7 +413,7 @@ export function useCommandCenter() {
const handleKeyEvent = useCallback(
(key: string): boolean => {
if (!open || addProjectRequest) return false;
if (!open) return false;
const currentItems = itemsRef.current;
if (key === "Escape") {
@@ -445,11 +442,11 @@ export function useCommandCenter() {
return false;
},
[addProjectRequest, open],
[open],
);
useEffect(() => {
if (!open || addProjectRequest || !isWeb) return;
if (!open || !isWeb) return;
const handler = (event: KeyboardEvent) => {
if (
@@ -468,7 +465,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);
}, [addProjectRequest, open, handleKeyEvent]);
}, [open, handleKeyEvent]);
return {
open,
+1 -12
View File
@@ -1,16 +1,5 @@
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],
);
return useAddProjectFlowStore((state) => state.open);
}
+30 -82
View File
@@ -1,14 +1,10 @@
import { describe, expect, it } from "vitest";
import {
cloneGithubProjectDirectly,
getOpenProjectFailureReason,
openGithubRepoDirectly,
openProjectDirectly,
} from "@/hooks/open-project";
import type {
EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
WorkspaceDescriptor,
} from "@/stores/session-store";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
@@ -22,35 +18,11 @@ function buildProjectPayload() {
};
}
function buildWorkspacePayload() {
return {
id: "1",
projectId: "1",
projectDisplayName: "project",
projectRootPath: PROJECT_PATH,
workspaceDirectory: PROJECT_PATH,
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
archivingAt: null,
status: "done" as const,
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
};
}
interface RecordedProject {
serverId: string;
project: ProjectWithoutWorkspacesDescriptor;
}
interface RecordedMerge {
serverId: string;
workspaces: WorkspaceDescriptor[];
}
interface RecordedHydrated {
serverId: string;
hydrated: boolean;
@@ -64,47 +36,31 @@ interface RecordedClone {
function createFakeSession() {
const projects: RecordedProject[] = [];
const merges: RecordedMerge[] = [];
const hydrated: RecordedHydrated[] = [];
return {
projects,
merges,
hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
projects.push({ serverId, project });
},
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
merges.push({ serverId, workspaces: Array.from(workspaces) });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
hydrated.push({ serverId, hydrated: value });
},
};
}
function createFakeNavigator() {
const navigations: NavigateToWorkspaceInput[] = [];
return {
navigations,
navigateToWorkspace: (input: NavigateToWorkspaceInput) => {
navigations.push(input);
return `/hosts/${input.serverId}/workspaces/${input.workspaceId}`;
},
};
}
function createFakeGithubCloneClient(workspace: ReturnType<typeof buildWorkspacePayload>) {
function createFakeGithubCloneClient(project: ReturnType<typeof buildProjectPayload> | null) {
const clones: RecordedClone[] = [];
return {
clones,
cloneGithubWorkspace: async (input: RecordedClone) => {
cloneGithubProject: async (input: RecordedClone) => {
clones.push(input);
return {
requestId: "request-3",
repo: "owner/project",
checkoutPath: PROJECT_PATH,
error: null,
workspace,
error: project ? null : "Project registration failed",
project,
};
},
};
@@ -131,7 +87,7 @@ describe("openProjectDirectly", () => {
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
expect(result).toEqual({ ok: true });
expect(result).toEqual({ ok: true, project: projectPayload });
expect(session.projects).toEqual([
{
serverId: SERVER_ID,
@@ -144,7 +100,6 @@ describe("openProjectDirectly", () => {
},
},
]);
expect(session.merges).toEqual([]);
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
});
@@ -205,26 +160,24 @@ describe("openProjectDirectly", () => {
});
});
describe("openGithubRepoDirectly", () => {
it("opens a cloned GitHub workspace and seeds a draft tab", async () => {
describe("cloneGithubProjectDirectly", () => {
it("registers a cloned GitHub project without creating a workspace", async () => {
const session = createFakeSession();
const navigator = createFakeNavigator();
const workspacePayload = buildWorkspacePayload();
const github = createFakeGithubCloneClient(workspacePayload);
const projectPayload = buildProjectPayload();
const github = createFakeGithubCloneClient(projectPayload);
const result = await openGithubRepoDirectly({
const result = await cloneGithubProjectDirectly({
serverId: SERVER_ID,
repo: "owner/project",
targetDirectory: "~/workspace",
cloneProtocol: "https",
isConnected: true,
client: github,
mergeWorkspaces: session.mergeWorkspaces,
addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toBe(true);
expect(result).toEqual({ ok: true, project: projectPayload });
expect(github.clones).toEqual([
{
repo: "owner/project",
@@ -232,45 +185,40 @@ describe("openGithubRepoDirectly", () => {
cloneProtocol: "https",
},
]);
expect(session.merges).toHaveLength(1);
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
expect(session.merges[0]?.workspaces[0]).toMatchObject({
id: "1",
projectId: "1",
projectRootPath: PROJECT_PATH,
workspaceDirectory: PROJECT_PATH,
});
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
expect(navigator.navigations).toEqual([
expect(session.projects).toEqual([
{
serverId: SERVER_ID,
workspaceId: "1",
target: { kind: "draft", draftId: expect.any(String) },
project: {
...projectPayload,
projectCustomName: null,
},
},
]);
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
});
it("rejects a workspace without an identity before changing app state", async () => {
it("does not register a project when cloning fails", async () => {
const session = createFakeSession();
const navigator = createFakeNavigator();
const github = createFakeGithubCloneClient({ ...buildWorkspacePayload(), id: " " });
const github = createFakeGithubCloneClient(null);
const result = await openGithubRepoDirectly({
const result = await cloneGithubProjectDirectly({
serverId: SERVER_ID,
repo: "owner/project",
targetDirectory: "~/workspace",
cloneProtocol: "https",
isConnected: true,
client: github,
mergeWorkspaces: session.mergeWorkspaces,
addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toBe(false);
expect(session.merges).toEqual([]);
expect(result).toEqual({
ok: false,
errorCode: null,
error: "Project registration failed",
});
expect(session.projects).toEqual([]);
expect(session.hydrated).toEqual([]);
expect(navigator.navigations).toEqual([]);
});
});
+10 -12
View File
@@ -1,12 +1,11 @@
import { useCallback } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import {
openGithubRepoDirectly,
cloneGithubProjectDirectly,
openProjectDirectly,
type OpenProjectResult,
type WorkspaceGithubCloneProtocol,
type ProjectGithubCloneProtocol,
} from "@/hooks/open-project";
export function useOpenProject(
@@ -47,33 +46,32 @@ export function useOpenProject(
);
}
export function useOpenGithubRepo(
export function useCloneGithubProject(
serverId: string | null,
): (
repo: string,
targetDirectory: string,
cloneProtocol?: WorkspaceGithubCloneProtocol,
) => Promise<boolean> {
cloneProtocol?: ProjectGithubCloneProtocol,
) => Promise<OpenProjectResult> {
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
async (repo: string, targetDirectory: string, cloneProtocol?: WorkspaceGithubCloneProtocol) => {
return openGithubRepoDirectly({
async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => {
return cloneGithubProjectDirectly({
serverId: normalizedServerId,
repo,
targetDirectory,
...(cloneProtocol ? { cloneProtocol } : {}),
isConnected,
client,
mergeWorkspaces,
addEmptyProject,
setHasHydratedWorkspaces,
navigateToWorkspace,
});
},
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
);
}