Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ describe("ProjectAddForm", () => {
currentClientMock = {};
});

test("creates a new git project and uses the backend response", async () => {
const createProject = mock(() =>
Promise.resolve({
success: true as const,
data: {
normalizedPath: "/projects/backend-normalized",
projectConfig: { workspaces: [] },
},
})
);
currentClientMock = {
projects: {
getDefaultProjectDir: () => Promise.resolve("/projects"),
list: () => Promise.resolve([]),
create: createProject,
},
};
const onSuccess = mock(() => undefined);

const { getByText, getByPlaceholderText } = render(
<ProjectAddForm isOpen onSuccess={onSuccess} />
);

fireEvent.click(getByText("New project"));
const projectInput = getByPlaceholderText("my-new-project");
const user = userEvent.setup({ document: projectInput.ownerDocument });
await user.type(projectInput, "prototype");
fireEvent.click(getByText("Create Project"));

await waitFor(() =>
expect(createProject).toHaveBeenCalledWith({ projectPath: "prototype", initGit: true })
);
await waitFor(() =>
expect(onSuccess).toHaveBeenCalledWith("/projects/backend-normalized", { workspaces: [] })
);
});

test("aborts in-flight clone when unmounted", async () => {
let receivedSignal: AbortSignal | null = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ export const LocalFolder: AppStory = {
},
};

/**
* Three labeled modes must fit narrow dialogs without overflowing the right edge;
* the pinned phone viewport guards the wrap layout (dialogs portal to body, so a
* fixed-width decorator cannot constrain them).
*/
export const PhoneViewport: AppStory = {
globals: {
viewport: { value: "mobile1", isRotated: false },
},
parameters: {
...appMeta.parameters,
pixel: {
matrix: { themes: ["dark"], viewports: ["phone"] },
},
},
render: () => <AppWithMocks setup={setupProjectCreateStory} />,
play: async ({ canvasElement }) => {
await openNewProjectModal(canvasElement);
},
};

/** "Clone repo" tab of the Add Project modal. */
export const CloneRepo: AppStory = {
// Integration: stories navigate via sidebar → "Add project" button to open the modal portal.
Expand Down
111 changes: 73 additions & 38 deletions src/browser/components/ProjectCreateModal/ProjectCreateModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { FolderOpen, Github } from "lucide-react";
import { FolderOpen, FolderPlus, Github } from "lucide-react";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -95,6 +95,8 @@ interface ProjectCreateFormProps {
submitLabel?: string;
/** Optional override for the path placeholder. */
placeholder?: string;
/** Create and initialize a new git repository instead of adding an existing folder. */
createNewGitRepo?: boolean;
/** Hide the footer actions (submit/cancel buttons). */
hideFooter?: boolean;
onErrorChange?: (hasError: boolean) => void;
Expand All @@ -114,16 +116,24 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
showCancelButton = false,
autoFocus = false,
onIsCreatingChange,
submitLabel = "Add Project",
placeholder = window.api?.platform === "win32"
? "C:\\Users\\user\\projects\\my-project"
: "/home/user/projects/my-project",
submitLabel,
placeholder,
createNewGitRepo = false,
hideFooter = false,
onErrorChange,
},
ref
) {
const { api } = useAPI();
const resolvedSubmitLabel =
submitLabel ?? (createNewGitRepo ? "Create Project" : "Add Project");
const resolvedPlaceholder =
placeholder ??
(createNewGitRepo
? "my-new-project"
: window.api?.platform === "win32"
? "C:\\Users\\user\\projects\\my-project"
: "/home/user/projects/my-project");
const [path, setPath] = useState(initialPath ?? "");
const [error, setErrorState] = useState("");
const [isCreating, setIsCreating] = useState(false);
Expand Down Expand Up @@ -192,7 +202,10 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
const existingPaths = new Map(existingProjects);

// Backend handles path resolution (bare names → ~/.xum/projects/name)
const result = await api.projects.create({ projectPath: trimmedPath });
const result = await api.projects.create({
projectPath: trimmedPath,
initGit: createNewGitRepo || undefined,
});

if (result.success) {
// Check if duplicate (backend may normalize the path)
Expand Down Expand Up @@ -221,7 +234,7 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
} finally {
setCreating(false);
}
}, [api, isCreating, onClose, onSuccess, path, reset, setCreating, setError]);
}, [api, createNewGitRepo, isCreating, onClose, onSuccess, path, reset, setCreating, setError]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
Expand Down Expand Up @@ -255,7 +268,7 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
setError("");
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
autoFocus={autoFocus}
disabled={isCreating}
className="border-border-medium bg-modal-bg text-foreground placeholder:text-muted focus:border-accent min-w-0 flex-1 rounded border px-3 py-2 font-mono text-sm focus:outline-none disabled:opacity-50"
Expand All @@ -273,6 +286,13 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
</div>
</div>

{createNewGitRepo && (
<p className="text-muted text-xs">
Bare names are created in the default projects directory and initialized as git
repositories.
</p>
)}

{error && <p className="text-error text-xs">{error}</p>}

{!hideFooter && (
Expand All @@ -283,7 +303,7 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje
</Button>
)}
<Button onClick={() => void handleSelect()} disabled={isCreating}>
{isCreating ? "Adding..." : submitLabel}
{isCreating ? (createNewGitRepo ? "Creating..." : "Adding...") : resolvedSubmitLabel}
</Button>
</DialogFooter>
)}
Expand All @@ -296,8 +316,7 @@ export const ProjectCreateForm = React.forwardRef<ProjectCreateFormHandle, Proje

ProjectCreateForm.displayName = "ProjectCreateForm";

// Keep the existing path-based add flow unchanged while adding clone as an alternate mode.
export type ProjectCreateMode = "pick-folder" | "clone";
export type ProjectCreateMode = "pick-folder" | "clone" | "new";

interface ProjectCloneFormProps {
onSuccess: (normalizedPath: string, projectConfig: ProjectConfig) => void;
Expand Down Expand Up @@ -776,14 +795,21 @@ function ProjectAddFormFooter(props: {
onClose?: () => void;
}) {
const handleSubmit = () => {
if (props.mode === "pick-folder") {
void props.createFormRef.current?.submit();
} else {
if (props.mode === "clone") {
void props.cloneFormRef.current?.submit();
} else {
void props.createFormRef.current?.submit();
}
};

const actionLabel = props.mode === "pick-folder" ? "Add Project" : "Clone Project";
const actionLabel =
props.mode === "pick-folder"
? "Add Project"
: props.mode === "clone"
? "Clone Project"
: "Create Project";
const creatingLabel =
props.mode === "pick-folder" ? "Adding…" : props.mode === "clone" ? "Cloning…" : "Creating…";

return (
<DialogFooter className={props.showCancelButton ? "justify-between" : undefined}>
Expand All @@ -793,7 +819,7 @@ function ProjectAddFormFooter(props: {
</Button>
)}
<Button onClick={handleSubmit} disabled={props.isCreating}>
{props.isCreating ? (props.mode === "pick-folder" ? "Adding…" : "Cloning…") : actionLabel}
{props.isCreating ? creatingLabel : actionLabel}
</Button>
</DialogFooter>
);
Expand Down Expand Up @@ -888,7 +914,7 @@ export const ProjectAddForm = React.forwardRef<ProjectAddFormHandle, ProjectAddF
const onErrorChange = props.onErrorChange;
const handleModeChange = useCallback(
(nextMode: string) => {
if (nextMode !== "pick-folder" && nextMode !== "clone") {
if (nextMode !== "pick-folder" && nextMode !== "clone" && nextMode !== "new") {
return;
}

Expand All @@ -907,16 +933,16 @@ export const ProjectAddForm = React.forwardRef<ProjectAddFormHandle, ProjectAddF
ref,
() => ({
submit: async () => {
if (mode === "pick-folder") {
return (await projectCreateFormRef.current?.submit()) ?? false;
if (mode === "clone") {
return (await projectCloneFormRef.current?.submit()) ?? false;
}
return (await projectCloneFormRef.current?.submit()) ?? false;
return (await projectCreateFormRef.current?.submit()) ?? false;
},
getTrimmedInput: () => {
if (mode === "pick-folder") {
return projectCreateFormRef.current?.getTrimmedPath() ?? "";
if (mode === "clone") {
return projectCloneFormRef.current?.getTrimmedRepoUrl() ?? "";
}
return projectCloneFormRef.current?.getTrimmedRepoUrl() ?? "";
return projectCreateFormRef.current?.getTrimmedPath() ?? "";
},
getMode: () => mode,
}),
Expand All @@ -929,12 +955,13 @@ export const ProjectAddForm = React.forwardRef<ProjectAddFormHandle, ProjectAddF
visually cohesive, while DialogFooter renders outside the wrapper
as a direct DialogContent grid child for proper edge alignment. */}
<div className="space-y-3">
{/* flex-wrap keeps the three labeled modes inside narrow dialogs (~375px). */}
<ToggleGroup
type="single"
value={mode}
onValueChange={handleModeChange}
disabled={isCreating}
className="h-9 bg-transparent"
className="h-auto min-h-9 flex-wrap gap-y-1 bg-transparent"
>
<ToggleGroupItem value="pick-folder" size="sm" className="h-7 gap-1.5 px-3 text-[13px]">
<FolderOpen className="h-3.5 w-3.5" />
Expand All @@ -944,21 +971,13 @@ export const ProjectAddForm = React.forwardRef<ProjectAddFormHandle, ProjectAddF
<Github className="h-3.5 w-3.5" />
Clone repo
</ToggleGroupItem>
<ToggleGroupItem value="new" size="sm" className="h-7 gap-1.5 px-3 text-[13px]">
<FolderPlus className="h-3.5 w-3.5" />
New project
</ToggleGroupItem>
Comment thread
ibetitsmike marked this conversation as resolved.
</ToggleGroup>

{mode === "pick-folder" ? (
<ProjectCreateForm
initialPath={props.initialPath}
ref={projectCreateFormRef}
onSuccess={props.onSuccess}
onClose={props.onClose}
showCancelButton={props.showCancelButton ?? false}
autoFocus={props.autoFocus}
onIsCreatingChange={setCreating}
onErrorChange={props.onErrorChange}
hideFooter
/>
) : (
{mode === "clone" ? (
<ProjectCloneForm
ref={projectCloneFormRef}
onSuccess={props.onSuccess}
Expand All @@ -970,6 +989,20 @@ export const ProjectAddForm = React.forwardRef<ProjectAddFormHandle, ProjectAddF
hideFooter
autoFocus={props.autoFocus}
/>
) : (
<ProjectCreateForm
key={mode}
initialPath={props.initialPath}
ref={projectCreateFormRef}
onSuccess={props.onSuccess}
onClose={props.onClose}
showCancelButton={props.showCancelButton ?? false}
autoFocus={props.autoFocus}
onIsCreatingChange={setCreating}
onErrorChange={props.onErrorChange}
createNewGitRepo={mode === "new"}
hideFooter
/>
)}
</div>

Expand Down Expand Up @@ -1020,7 +1053,9 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Add Project</DialogTitle>
<DialogDescription>Pick a folder or clone a project repository</DialogDescription>
<DialogDescription>
Pick a folder, clone a repository, or create a new project
</DialogDescription>
</DialogHeader>

<ProjectAddForm
Expand Down
4 changes: 2 additions & 2 deletions src/browser/features/SplashScreens/OnboardingWizardSplash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -823,8 +823,8 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
body: (
<>
<p>
Projects are the folders or repos you want Xum to work in. Add a local folder or clone
from GitHub, then click Next.
Projects are the folders or repos you want Xum to work in. Add a local folder, clone
from GitHub, or create a new project, then click Next.
</p>

{userProjects.size > 0 ? (
Expand Down
2 changes: 1 addition & 1 deletion src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ export const mcpOauth = {
// Projects
export const projects = {
create: {
input: z.object({ projectPath: z.string() }),
input: z.object({ projectPath: z.string(), initGit: z.boolean().optional() }),
output: ResultSchema(
z.object({
projectConfig: ProjectConfigSchema,
Expand Down
2 changes: 1 addition & 1 deletion src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3293,7 +3293,7 @@ export const router = (authToken?: string) => {
.input(schemas.projects.create.input)
.output(schemas.projects.create.output)
.handler(async ({ context, input }) => {
return context.projectService.create(input.projectPath);
return context.projectService.create(input.projectPath, { initGit: input.initGit });
}),
getDefaultProjectDir: t
.input(schemas.projects.getDefaultProjectDir.input)
Expand Down
Loading
Loading