Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion src/renderer/actions/createProjectActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";

const mocks = vi.hoisted(() => ({
createProjectDirectory: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
cloneRepo: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
pickFolder: vi.fn<(d?: string) => Promise<string | null>>(),
addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({
id: "p1",
Expand All @@ -22,6 +23,7 @@ vi.mock("@/renderer/bridge", () => ({
readBridge: () => ({
platform: "darwin",
createProjectDirectory: mocks.createProjectDirectory,
cloneRepo: mocks.cloneRepo,
pickFolder: mocks.pickFolder,
}),
}));
Expand All @@ -43,7 +45,13 @@ vi.mock("@/renderer/utils/gitHelpers", () => ({
autoDetectSetupScript: mocks.autoDetectSetupScript,
}));

import { addExistingProject, commitCreateProject } from "./createProjectActions";
import {
addExistingProject,
commitCloneProject,
commitCreateProject,
} from "./createProjectActions";

const { cloneRepo } = mocks;

describe("commitCreateProject", () => {
beforeEach(() => {
Expand Down Expand Up @@ -103,6 +111,78 @@ describe("commitCreateProject", () => {
});
});

describe("commitCloneProject", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("github source: clones, then adds the project at the returned path", async () => {
cloneRepo.mockResolvedValue({ path: "/Users/me/code/lightcode" });

await commitCloneProject({
choice: { kind: "native" },
parentDir: "/Users/me/code",
name: "lightcode",
source: {
kind: "github",
nameWithOwner: "SDSLeon/lightcode",
account: { host: "github.com", login: "SDSLeon" },
},
});

expect(cloneRepo).toHaveBeenCalledWith({
parentLocation: { kind: "posix", path: "/Users/me/code" },
name: "lightcode",
source: {
kind: "github",
nameWithOwner: "SDSLeon/lightcode",
account: { host: "github.com", login: "SDSLeon" },
},
});
expect(addProject).toHaveBeenCalledWith(
{ kind: "posix", path: "/Users/me/code/lightcode" },
"lightcode",
);
// Records the parent the user cloned into, not the new folder.
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
expect(openDraft).toHaveBeenCalledWith("p1");
});

test("url source: passes the url through and opens the clone", async () => {
cloneRepo.mockResolvedValue({ path: "/Users/me/code/repo" });

await commitCloneProject({
choice: { kind: "native" },
parentDir: "/Users/me/code",
name: "repo",
source: { kind: "url", url: "https://github.com/owner/repo.git" },
});

expect(cloneRepo).toHaveBeenCalledWith({
parentLocation: { kind: "posix", path: "/Users/me/code" },
name: "repo",
source: { kind: "url", url: "https://github.com/owner/repo.git" },
});
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/repo" }, "repo");
});

test("clone failure propagates and does not add a project", async () => {
cloneRepo.mockRejectedValue(new Error("Authentication failed"));

await expect(
commitCloneProject({
choice: { kind: "native" },
parentDir: "/Users/me/code",
name: "repo",
source: { kind: "url", url: "bad" },
}),
).rejects.toThrow(/authentication/i);

expect(addProject).not.toHaveBeenCalled();
expect(setLastUsedProjectDir).not.toHaveBeenCalled();
});
});

describe("addExistingProject", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
71 changes: 63 additions & 8 deletions src/renderer/actions/createProjectActions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { startTransition } from "react";
import type { ProjectLocation } from "@/shared/contracts";
import type { CloneRepoSource, ProjectLocation } from "@/shared/contracts";
import {
deriveLocationFromPath,
parentDirOf,
runtimeKeyForLocation,
scratchKindForChoice,
wslHomeDir,
type RuntimeChoice,
} from "@/shared/createProject";
import { getProjectFsPath } from "@/shared/wsl";
Expand All @@ -25,6 +26,21 @@ export interface CommitCreateProjectParams {
name: string;
}

/**
* Register a freshly materialized project: remember the browsed parent per
* runtime (so the next create/clone starts there), add it to the store, detect
* its setup script, and open its draft. Shared by the create and clone flows.
*/
function registerNewProject(location: ProjectLocation, name: string, lastUsedDir: string): void {
useSharedSettings.getState().setLastUsedProjectDir(runtimeKeyForLocation(location), lastUsedDir);

startTransition(() => {
const project = useAppStore.getState().addProject(location, name || undefined);
autoDetectSetupScript(project);
useAppStore.getState().openDraft(project.id);
});
}

/**
* Finalize project creation: for "scratch" create the directory on disk first,
* then add the project, remember the browsed parent per runtime, and open its
Expand All @@ -48,13 +64,7 @@ export async function commitCreateProject(params: CommitCreateProjectParams): Pr
lastUsedDir = parentDirOf(params.dir, location.kind);
}

useSharedSettings.getState().setLastUsedProjectDir(runtimeKeyForLocation(location), lastUsedDir);

startTransition(() => {
const project = useAppStore.getState().addProject(location, name || undefined);
autoDetectSetupScript(project);
useAppStore.getState().openDraft(project.id);
});
registerNewProject(location, name, lastUsedDir);
}

/**
Expand Down Expand Up @@ -82,3 +92,48 @@ export async function addExistingProject(): Promise<void> {
name: "",
});
}

/**
* A `ProjectLocation` to run the GitHub CLI in for a runtime choice (used when
* listing accounts/repos, before a project exists). Native resolves to the
* host's home dir; a WSL choice resolves to that distro's `/home` over the UNC
* bridge. The cwd only needs to be a valid directory for `gh` — listing isn't
* tied to any repo.
*/
export function resolveRuntimeContextLocation(choice: RuntimeChoice): Promise<ProjectLocation> {
if (choice.kind === "wsl") {
return Promise.resolve(
deriveLocationFromPath(wslHomeDir(choice.distro), readBridge().platform),
);
}
return loadHomeScopeLocation();
}

export interface CommitCloneProjectParams {
choice: RuntimeChoice;
/** Existing parent folder to clone into. */
parentDir: string;
/** New folder name for the clone. */
name: string;
source: CloneRepoSource;
}

/**
* Clone a repository into `parentDir/name`, then add it as a project, remember
* the parent per runtime (so the next clone/create starts there), and open its
* draft. Errors (auth, network, an existing folder) propagate so the modal can
* surface them.
*/
export async function commitCloneProject(params: CommitCloneProjectParams): Promise<void> {
const platform = readBridge().platform;
const name = params.name.trim();
const parentLocation = deriveLocationFromPath(params.parentDir, platform);

const { path } = await readBridge().cloneRepo({
parentLocation,
name,
source: params.source,
});

registerNewProject(deriveLocationFromPath(path, platform), name, params.parentDir);
}
9 changes: 9 additions & 0 deletions src/renderer/state/panelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ interface PanelState {
threadSearchOpen: boolean;
/** Whether the "Start from scratch" create-project modal is open. */
createProjectModalOpen: boolean;
/** Whether the "Clone a repository" modal is open. */
cloneProjectModalOpen: boolean;
setGitReviewContext: (ctx: GitReviewContext | null) => void;
setThreadSortMode: (mode: ThreadSortMode) => void;
setGitReviewAsPanel: (v: boolean) => void;
Expand All @@ -76,6 +78,8 @@ interface PanelState {
closeThreadSearch: () => void;
openCreateProjectModal: () => void;
closeCreateProjectModal: () => void;
openCloneProjectModal: () => void;
closeCloneProjectModal: () => void;
closeAllPanels: () => void;
}

Expand Down Expand Up @@ -129,6 +133,7 @@ export const usePanelStore = create<PanelState>((set) => ({
threadSortMode: "updated",
threadSearchOpen: false,
createProjectModalOpen: false,
cloneProjectModalOpen: false,

setGitReviewContext: (ctx) => {
const prev = usePanelStore.getState().gitReviewContext;
Expand Down Expand Up @@ -267,6 +272,10 @@ export const usePanelStore = create<PanelState>((set) => ({
set((state) => (state.createProjectModalOpen ? {} : { createProjectModalOpen: true })),
closeCreateProjectModal: () =>
set((state) => (state.createProjectModalOpen ? { createProjectModalOpen: false } : {})),
openCloneProjectModal: () =>
set((state) => (state.cloneProjectModalOpen ? {} : { cloneProjectModalOpen: true })),
closeCloneProjectModal: () =>
set((state) => (state.cloneProjectModalOpen ? { cloneProjectModalOpen: false } : {})),
closeAllPanels: () => {
localStorage.removeItem(STORAGE_KEY);
set((state) => {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/MainView/parts/AppOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { WelcomeOverlay } from "@/renderer/views/WelcomeOverlay";
import { BrowserOverlay } from "@/renderer/views/MainView/parts/BrowserOverlay";
import { LoginTerminalOverlay } from "@/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay";
import { CreateProjectModal } from "@/renderer/views/MainView/parts/CreateProject/CreateProjectModal";
import { CloneProjectModal } from "@/renderer/views/MainView/parts/CreateProject/CloneProjectModal";

export function AppOverlays() {
const projects = useAppStore((s) => s.projects);
Expand Down Expand Up @@ -182,6 +183,7 @@ export function AppOverlays() {
<UsageLoginConfirmationDialog />
<LoginTerminalOverlay />
<CreateProjectModal />
<CloneProjectModal />
</>
);
}
Expand Down
Loading