From 286ff6b8ff64148c7513c3b6f103bfdc864cbe03 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 13 Jun 2026 22:27:08 -0700 Subject: [PATCH 1/3] feat(create-project): add GitHub repo clone flow - add the clone-project modal and menu entry in the renderer - add GitHub account/repo listing plus clone IPC/runtime support - extend WSL bridge timeouts for long-running clone commands --- .../actions/createProjectActions.test.ts | 82 ++- src/renderer/actions/createProjectActions.ts | 71 ++- src/renderer/state/panelStore.ts | 9 + .../views/MainView/parts/AppOverlays.tsx | 2 + .../CreateProject/CloneProjectModal.test.tsx | 134 ++++ .../parts/CreateProject/CloneProjectModal.tsx | 587 ++++++++++++++++++ .../CreateProject/CreateProjectMenu.test.tsx | 21 +- .../parts/CreateProject/CreateProjectMenu.tsx | 27 +- src/shared/contracts/github.ts | 81 +++ src/shared/createProject.test.ts | 34 + src/shared/createProject.ts | 21 + src/shared/ipc/procedures/github.ts | 24 + src/supervisor/git.ts | 17 + src/supervisor/git/exec.ts | 17 +- src/supervisor/github.test.ts | 226 ++++++- src/supervisor/github.ts | 196 +++++- src/supervisor/ipcHandlers.ts | 3 + src/supervisor/runtime.ts | 27 + src/supervisor/wsl/bridge/bridge.mjs | 5 +- src/supervisor/wsl/bridge/client.test.ts | 26 +- src/supervisor/wsl/bridge/client.ts | 35 +- 21 files changed, 1612 insertions(+), 33 deletions(-) create mode 100644 src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx create mode 100644 src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx diff --git a/src/renderer/actions/createProjectActions.test.ts b/src/renderer/actions/createProjectActions.test.ts index 93f4a385..7c791235 100644 --- a/src/renderer/actions/createProjectActions.test.ts +++ b/src/renderer/actions/createProjectActions.test.ts @@ -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>(), addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({ id: "p1", @@ -22,6 +23,7 @@ vi.mock("@/renderer/bridge", () => ({ readBridge: () => ({ platform: "darwin", createProjectDirectory: mocks.createProjectDirectory, + cloneRepo: mocks.cloneRepo, pickFolder: mocks.pickFolder, }), })); @@ -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(() => { @@ -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(); diff --git a/src/renderer/actions/createProjectActions.ts b/src/renderer/actions/createProjectActions.ts index d7ff8931..55b5ca8a 100644 --- a/src/renderer/actions/createProjectActions.ts +++ b/src/renderer/actions/createProjectActions.ts @@ -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"; @@ -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 @@ -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); } /** @@ -82,3 +92,48 @@ export async function addExistingProject(): Promise { 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 { + 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 { + 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); +} diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index 94017a1c..22325c38 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -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; @@ -76,6 +78,8 @@ interface PanelState { closeThreadSearch: () => void; openCreateProjectModal: () => void; closeCreateProjectModal: () => void; + openCloneProjectModal: () => void; + closeCloneProjectModal: () => void; closeAllPanels: () => void; } @@ -129,6 +133,7 @@ export const usePanelStore = create((set) => ({ threadSortMode: "updated", threadSearchOpen: false, createProjectModalOpen: false, + cloneProjectModalOpen: false, setGitReviewContext: (ctx) => { const prev = usePanelStore.getState().gitReviewContext; @@ -267,6 +272,10 @@ export const usePanelStore = create((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) => { diff --git a/src/renderer/views/MainView/parts/AppOverlays.tsx b/src/renderer/views/MainView/parts/AppOverlays.tsx index b3ef728b..9a6ecb05 100644 --- a/src/renderer/views/MainView/parts/AppOverlays.tsx +++ b/src/renderer/views/MainView/parts/AppOverlays.tsx @@ -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); @@ -182,6 +183,7 @@ export function AppOverlays() { + ); } diff --git a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx new file mode 100644 index 00000000..7efce8f6 --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { GhListAccountsResult, GhListReposResult } from "@/shared/contracts"; + +const mocks = vi.hoisted(() => ({ + listWslDistros: vi.fn<() => Promise>().mockResolvedValue([]), + pickFolder: vi.fn<(d?: string) => Promise>().mockResolvedValue(null), + ghListAccounts: vi.fn<() => Promise>(), + ghListRepos: vi.fn<() => Promise>(), + loadHomeScopeLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(), + resolveRuntimeContextLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(), + commitCloneProject: vi.fn<(p: unknown) => Promise>().mockResolvedValue(undefined), +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ + platform: "darwin", + listWslDistros: mocks.listWslDistros, + pickFolder: mocks.pickFolder, + ghListAccounts: mocks.ghListAccounts, + ghListRepos: mocks.ghListRepos, + }), + isWindows: () => false, +})); +vi.mock("@/renderer/actions/projectActions", () => ({ + loadHomeScopeLocation: mocks.loadHomeScopeLocation, +})); +vi.mock("@/renderer/actions/createProjectActions", () => ({ + resolveRuntimeContextLocation: mocks.resolveRuntimeContextLocation, + commitCloneProject: mocks.commitCloneProject, +})); + +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { CloneProjectModal } from "./CloneProjectModal"; + +const ONE_ACCOUNT: GhListAccountsResult = { + accounts: [{ host: "github.com", login: "SDSLeon", active: true }], +}; +const ONE_REPO: GhListReposResult = { + repos: [ + { + nameWithOwner: "SDSLeon/lightcode", + owner: "SDSLeon", + name: "lightcode", + description: "agents", + isPrivate: false, + isFork: false, + sshUrl: "git@github.com:SDSLeon/lightcode.git", + httpsUrl: "https://github.com/SDSLeon/lightcode.git", + pushedAt: "2026-06-01T00:00:00Z", + }, + ], +}; + +describe("CloneProjectModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listWslDistros.mockResolvedValue([]); + mocks.pickFolder.mockResolvedValue(null); + mocks.ghListAccounts.mockResolvedValue(ONE_ACCOUNT); + mocks.ghListRepos.mockResolvedValue(ONE_REPO); + mocks.loadHomeScopeLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" }); + mocks.resolveRuntimeContextLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" }); + mocks.commitCloneProject.mockResolvedValue(undefined); + useSharedSettings.setState({ lastUsedProjectDirs: {} }); + usePanelStore.setState({ cloneProjectModalOpen: false }); + }); + + afterEach(() => { + usePanelStore.setState({ cloneProjectModalOpen: false }); + }); + + test("github mode: selecting a repo fills the folder name and clones it", async () => { + usePanelStore.getState().openCloneProjectModal(); + render(); + + const cloneButton = await screen.findByRole("button", { name: "Clone" }); + expect(cloneButton).toBeDisabled(); + + fireEvent.click(await screen.findByText("SDSLeon/lightcode")); + + await waitFor(() => expect(screen.getByLabelText("Folder name")).toHaveValue("lightcode")); + await waitFor(() => expect(cloneButton).toBeEnabled()); + + fireEvent.click(cloneButton); + + await waitFor(() => + expect(mocks.commitCloneProject).toHaveBeenCalledWith({ + choice: { kind: "native" }, + parentDir: "/Users/me", + name: "lightcode", + source: { + kind: "github", + nameWithOwner: "SDSLeon/lightcode", + account: { host: "github.com", login: "SDSLeon" }, + }, + }), + ); + }); + + test("url mode: pasting a URL fills the name and clones via url source", async () => { + usePanelStore.getState().openCloneProjectModal(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Clone URL" })); + + const urlInput = screen.getByLabelText("Repository URL"); + fireEvent.change(urlInput, { target: { value: "https://github.com/owner/repo.git" } }); + + await waitFor(() => expect(screen.getByLabelText("Folder name")).toHaveValue("repo")); + + const cloneButton = screen.getByRole("button", { name: "Clone" }); + await waitFor(() => expect(cloneButton).toBeEnabled()); + fireEvent.click(cloneButton); + + await waitFor(() => + expect(mocks.commitCloneProject).toHaveBeenCalledWith({ + choice: { kind: "native" }, + parentDir: "/Users/me", + name: "repo", + source: { kind: "url", url: "https://github.com/owner/repo.git" }, + }), + ); + }); + + test("falls back to URL mode when no GitHub accounts are signed in", async () => { + mocks.ghListAccounts.mockResolvedValue({ accounts: [] }); + usePanelStore.getState().openCloneProjectModal(); + render(); + + await waitFor(() => expect(screen.getByLabelText("Repository URL")).toBeInTheDocument()); + }); +}); diff --git a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx new file mode 100644 index 00000000..d2dcefd0 --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx @@ -0,0 +1,587 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ChevronDown, FolderOpen, Link2, Lock, Monitor, Search } from "lucide-react"; +import { Button, Dropdown, Label, Modal } from "@heroui/react"; +import type { GitHubAccount, GitHubRepoSummary } from "@/shared/contracts"; +import { + cloneFolderNameFromRepo, + cloneFolderNameFromUrl, + splitPathLeaf, + validateProjectName, + validateScratchParent, + wslHomeDir, + type RuntimeChoice, +} from "@/shared/createProject"; +import { getProjectFsPath } from "@/shared/wsl"; +import { readBridge } from "@/renderer/bridge"; +import { loadHomeScopeLocation } from "@/renderer/actions/projectActions"; +import { + commitCloneProject, + resolveRuntimeContextLocation, +} from "@/renderer/actions/createProjectActions"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { Input, TuxIcon } from "@/renderer/components/common"; +import { formatRelativeTime } from "@/renderer/utils/formatTime"; + +type CloneMode = "github" | "url"; + +/** GitHub "mark" logo — lucide dropped brand icons, so we inline it. */ +function GithubMark(props: { className?: string }) { + return ( + + ); +} + +function choiceForRuntime(runtimeKey: string): RuntimeChoice { + return runtimeKey === "native" ? { kind: "native" } : { kind: "wsl", distro: runtimeKey }; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +/** + * Modal for the "Clone a repository" flow. Two modes: browse a signed-in + * GitHub CLI account's repositories, or paste any git clone URL. Either way the + * clone lands in a chosen parent folder (defaulting to the last-used one, like + * "Start from scratch") and is opened as a new project. + */ +export function CloneProjectModal() { + const open = usePanelStore((s) => s.cloneProjectModalOpen); + + return ( + { + if (!next) usePanelStore.getState().closeCloneProjectModal(); + }} + > + + + {open ? : null} + + + + ); +} + +function CloneProjectForm() { + const lastUsedProjectDirs = useSharedSettings((s) => s.lastUsedProjectDirs); + + const [distros, setDistros] = useState([]); + const [runtimeKey, setRuntimeKey] = useState("native"); + const [defaultDir, setDefaultDir] = useState(""); + const [dir, setDir] = useState(""); + + const [mode, setMode] = useState("github"); + const modeTouched = useRef(false); + + // null while loading; [] once we know there are none. + const [accounts, setAccounts] = useState(null); + const [selectedAccount, setSelectedAccount] = useState(null); + const [repos, setRepos] = useState(null); + const [reposError, setReposError] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(null); + + const [url, setUrl] = useState(""); + const [name, setName] = useState(""); + const nameTouched = useRef(false); + + const [busy, setBusy] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const choice = choiceForRuntime(runtimeKey); + const showRuntime = distros.length > 0; + + // Available WSL distros gate the runtime picker (Windows only in practice). + useEffect(() => { + let active = true; + void readBridge() + .listWslDistros() + .then((list) => { + if (active) setDistros(list); + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, []); + + // Resolve the default browse directory (last-used → home) for the runtime. + const lastForRuntime = lastUsedProjectDirs[runtimeKey]; + useEffect(() => { + let active = true; + if (lastForRuntime) { + setDefaultDir(lastForRuntime); + return; + } + if (runtimeKey !== "native") { + setDefaultDir(wslHomeDir(runtimeKey)); + return; + } + setDefaultDir(""); + void loadHomeScopeLocation() + .then((location) => { + if (active) setDefaultDir(getProjectFsPath(location)); + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [runtimeKey, lastForRuntime]); + + // Switching runtime clears the browsed folder and any error. + useEffect(() => { + setDir(""); + setSubmitError(null); + }, [runtimeKey]); + + // Load the signed-in GitHub CLI accounts for the chosen runtime. Keyed on the + // runtime only (not `mode`), so toggling the GitHub/Clone-URL tabs doesn't + // re-fetch accounts or discard the user's account/repo selection. + useEffect(() => { + let active = true; + setAccounts(null); + setSelectedAccount(null); + setRepos(null); + setReposError(null); + void resolveRuntimeContextLocation(choiceForRuntime(runtimeKey)) + .then((runtime) => readBridge().ghListAccounts({ runtime })) + .then((result) => { + if (!active) return; + setAccounts(result.accounts); + setSelectedAccount(result.accounts.find((a) => a.active) ?? result.accounts[0] ?? null); + // Nothing to browse → fall back to the paste-a-URL mode unless the user + // has already chosen a mode themselves. + if (result.accounts.length === 0 && !modeTouched.current) setMode("url"); + }) + .catch(() => { + if (active) setAccounts([]); + }); + return () => { + active = false; + }; + }, [runtimeKey]); + + // Load the selected account's repositories. + const accountKey = selectedAccount ? `${selectedAccount.host}\n${selectedAccount.login}` : null; + useEffect(() => { + if (!selectedAccount) return; + let active = true; + setRepos(null); + setReposError(null); + setSelectedRepo(null); + setRepoSearch(""); + const account = { host: selectedAccount.host, login: selectedAccount.login }; + void resolveRuntimeContextLocation(choiceForRuntime(runtimeKey)) + .then((runtime) => readBridge().ghListRepos({ runtime, account })) + .then((result) => { + if (active) setRepos(result.repos); + }) + .catch((error) => { + if (!active) return; + setRepos([]); + setReposError(errorMessage(error, "Couldn't list repositories.")); + }); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runtimeKey, accountKey]); + + const query = repoSearch.trim().toLowerCase(); + const filteredRepos = (repos ?? []).filter( + (r) => + !query || + r.nameWithOwner.toLowerCase().includes(query) || + r.description.toLowerCase().includes(query), + ); + + const scratchParent = dir || defaultDir; + const pickerLeaf = scratchParent ? splitPathLeaf(scratchParent) : null; + + const targetError = + mode === "github" + ? selectedRepo + ? null + : "Select a repository." + : url.trim() + ? null + : "Enter a repository URL."; + const nameError = validateProjectName(name); + const parentError = validateScratchParent(scratchParent, choice); + const validationError = targetError ?? nameError ?? parentError; + const parentMismatch = scratchParent ? parentError : null; + const inlineError = submitError ?? parentMismatch; + + function selectMode(next: CloneMode) { + modeTouched.current = true; + setMode(next); + setSubmitError(null); + } + + function selectRepo(repo: GitHubRepoSummary) { + setSelectedRepo(repo); + setSubmitError(null); + if (!nameTouched.current) setName(cloneFolderNameFromRepo(repo.nameWithOwner)); + } + + function changeUrl(value: string) { + setUrl(value); + setSubmitError(null); + if (!nameTouched.current) setName(cloneFolderNameFromUrl(value)); + } + + async function handleBrowse() { + const picked = await readBridge().pickFolder(scratchParent || undefined); + if (!picked) return; + setDir(picked); + setSubmitError(null); + } + + async function handleSubmit() { + if (validationError) return; + setBusy(true); + setSubmitError(null); + try { + const source = + mode === "github" + ? ({ + kind: "github", + nameWithOwner: selectedRepo!.nameWithOwner, + account: { host: selectedAccount!.host, login: selectedAccount!.login }, + } as const) + : ({ kind: "url", url: url.trim() } as const); + await commitCloneProject({ choice, parentDir: scratchParent, name, source }); + usePanelStore.getState().closeCloneProjectModal(); + } catch (error) { + setSubmitError(errorMessage(error, "Couldn't clone the repository.")); + } finally { + setBusy(false); + } + } + + const runtimeLabel = runtimeKey === "native" ? "Native" : runtimeKey; + + return ( + <> + + + Clone a repository +

+ Browse your GitHub repositories or paste a clone URL. +

+
+ + {/* Mode toggle */} +
+ selectMode("github")}> + + GitHub + + selectMode("url")}> + + Clone URL + +
+ + {mode === "github" ? ( + selectMode("url")} + /> + ) : ( +
+ + changeUrl(e.target.value)} + /> +
+ )} + + {/* Runtime (WSL only) */} + {showRuntime ? ( +
+ + + + + setRuntimeKey(String(key))} + > + + + + + {distros.map((distro) => ( + + + + + ))} + + + +
+ ) : null} + + {/* Folder name */} +
+ + { + nameTouched.current = true; + setName(e.target.value); + }} + /> +
+ + {/* Location */} +
+ + +
+ + {inlineError ?

{inlineError}

: null} +
+ + + + + + ); +} + +function ModeTab(props: { active: boolean; onPress: () => void; children: ReactNode }) { + return ( + + ); +} + +function GitHubBrowser(props: { + accounts: GitHubAccount[] | null; + selectedAccount: GitHubAccount | null; + onSelectAccount: (account: GitHubAccount) => void; + repos: GitHubRepoSummary[] | null; + reposError: string | null; + filteredRepos: GitHubRepoSummary[]; + repoSearch: string; + onSearch: (value: string) => void; + selectedRepoId: string | null; + onSelectRepo: (repo: GitHubRepoSummary) => void; + onSwitchToUrl: () => void; +}) { + const { accounts, selectedAccount } = props; + + if (accounts === null) { + return

Loading accounts…

; + } + + if (accounts.length === 0) { + return ( +
+ No GitHub CLI accounts found. Sign in with{" "} + gh auth login, or{" "} + + . +
+ ); + } + + return ( +
+ {accounts.length > 1 ? ( + + + + { + const [host, login] = String(key).split("\n"); + const next = accounts.find((a) => a.host === host && a.login === login); + if (next) props.onSelectAccount(next); + }} + > + {accounts.map((account) => ( + + + + {account.host} + + ))} + + + + ) : ( +
+ + {selectedAccount?.login} +
+ )} + +
+ + props.onSearch(e.target.value)} + className="pl-8" + /> +
+ + +
+ ); +} + +function RepoList(props: { + repos: GitHubRepoSummary[] | null; + reposError: string | null; + filteredRepos: GitHubRepoSummary[]; + selectedRepoId: string | null; + onSelectRepo: (repo: GitHubRepoSummary) => void; +}) { + if (props.reposError) { + return ( +
+ {props.reposError} +
+ ); + } + + if (props.repos === null) { + return

Loading repositories…

; + } + + if (props.filteredRepos.length === 0) { + return ( +

+ {props.repos.length === 0 ? "No repositories found." : "No matches."} +

+ ); + } + + return ( +
+ {props.filteredRepos.map((repo) => { + const selected = repo.nameWithOwner === props.selectedRepoId; + return ( + + ); + })} +
+ ); +} diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx index 13b82d81..fb79fdcb 100644 --- a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx @@ -16,9 +16,11 @@ import { CreateProjectMenu } from "./CreateProjectMenu"; describe("CreateProjectMenu", () => { beforeEach(() => { vi.clearAllMocks(); - usePanelStore.setState({ createProjectModalOpen: false }); + usePanelStore.setState({ createProjectModalOpen: false, cloneProjectModalOpen: false }); }); - afterEach(() => usePanelStore.setState({ createProjectModalOpen: false })); + afterEach(() => + usePanelStore.setState({ createProjectModalOpen: false, cloneProjectModalOpen: false }), + ); it("opens the scratch modal when 'Start from scratch' is chosen", async () => { render( @@ -34,6 +36,21 @@ describe("CreateProjectMenu", () => { expect(mocks.addExistingProject).not.toHaveBeenCalled(); }); + it("opens the clone modal when 'Clone a repository' is chosen", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + fireEvent.click(await screen.findByText("Clone a repository")); + + await waitFor(() => expect(usePanelStore.getState().cloneProjectModalOpen).toBe(true)); + expect(usePanelStore.getState().createProjectModalOpen).toBe(false); + expect(mocks.addExistingProject).not.toHaveBeenCalled(); + }); + it("goes straight to the folder picker for 'Use an existing folder' (no modal)", async () => { render( diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx index 9f65e9f4..b41cafd5 100644 --- a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { FilePlus, FolderOpen } from "lucide-react"; +import { FilePlus, FolderOpen, GitBranch } from "lucide-react"; import { Dropdown, Label } from "@heroui/react"; import { usePanelStore } from "@/renderer/state/panelStore"; import { @@ -7,17 +7,20 @@ import { type CreateProjectMode, } from "@/renderer/actions/createProjectActions"; +/** A menu choice: the two create modes plus "clone". */ +export type AddProjectAction = CreateProjectMode | "clone"; + /** * The "+" dropdown for creating a project. Wraps a caller-provided trigger * (so it can sit in the sidebar header or the welcome screen). "Start from - * scratch" opens the create-project modal; "Use an existing folder" goes - * straight to the native folder picker, as it always has. `onSelect` fires - * before either action so callers can dismiss surrounding UI (e.g. the - * welcome overlay). + * scratch" opens the create-project modal; "Clone a repository" opens the clone + * modal; "Use an existing folder" goes straight to the native folder picker, as + * it always has. `onSelect` fires before either action so callers can dismiss + * surrounding UI (e.g. the welcome overlay). */ export function CreateProjectMenu(props: { children: ReactNode; - onSelect?: (mode: CreateProjectMode) => void; + onSelect?: (action: AddProjectAction) => void; }) { return ( @@ -26,10 +29,12 @@ export function CreateProjectMenu(props: { { - const mode: CreateProjectMode = key === "scratch" ? "scratch" : "existing"; - props.onSelect?.(mode); - if (mode === "scratch") { + const action = key as AddProjectAction; + props.onSelect?.(action); + if (action === "scratch") { usePanelStore.getState().openCreateProjectModal(); + } else if (action === "clone") { + usePanelStore.getState().openCloneProjectModal(); } else { void addExistingProject(); } @@ -39,6 +44,10 @@ export function CreateProjectMenu(props: { + + + + diff --git a/src/shared/contracts/github.ts b/src/shared/contracts/github.ts index 1f542ed7..adc78751 100644 --- a/src/shared/contracts/github.ts +++ b/src/shared/contracts/github.ts @@ -228,3 +228,84 @@ export const ghPostPrCommentPayloadSchema = z.object({ body: z.string().min(1), }); export type GhPostPrCommentPayload = z.infer; + +// --- Clone a repository --------------------------------------------------- +// "Add project → Clone a repository" browses the GitHub CLI's signed-in +// accounts and the repositories each can reach, then clones into a local +// folder. Listing and cloning run wherever `gh`/`git` live for the chosen +// runtime, so every payload carries a `ProjectLocation` to anchor the cwd and +// (for WSL) the bridge. + +/** One account the GitHub CLI is signed in to (from `gh auth status`). */ +export interface GitHubAccount { + host: string; + login: string; + /** The host's currently active account — used as the default selection. */ + active: boolean; +} + +export interface GhListAccountsResult { + accounts: GitHubAccount[]; +} + +/** A repository the selected account can clone, shaped for the picker. */ +export interface GitHubRepoSummary { + /** "owner/name". */ + nameWithOwner: string; + owner: string; + name: string; + description: string; + isPrivate: boolean; + isFork: boolean; + sshUrl: string; + httpsUrl: string; + /** ISO timestamp of the last push; the list is sorted by this descending. */ + pushedAt: string; +} + +export interface GhListReposResult { + repos: GitHubRepoSummary[]; +} + +export interface CloneRepoResult { + /** Absolute path of the freshly cloned project folder. */ + path: string; +} + +/** Identifies a signed-in account so the supervisor can scope `gh` to it. */ +const gitHubAccountRefSchema = z.object({ + host: z.string().min(1), + login: z.string().min(1), +}); +export type GitHubAccountRef = z.infer; + +export const ghListAccountsPayloadSchema = z.object({ + /** Runtime context (cwd / WSL distro) the `gh` CLI should run in. */ + runtime: projectLocationSchema, +}); +export type GhListAccountsPayload = z.infer; + +export const ghListReposPayloadSchema = z.object({ + runtime: projectLocationSchema, + account: gitHubAccountRefSchema, +}); +export type GhListReposPayload = z.infer; + +export const cloneRepoSourceSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("url"), url: z.string().min(1) }), + z.object({ + kind: z.literal("github"), + nameWithOwner: z.string().min(1), + account: gitHubAccountRefSchema, + }), +]); +export type CloneRepoSource = z.infer; + +export const cloneRepoPayloadSchema = z.object({ + /** The existing parent folder to clone into; its kind drives the runtime. */ + parentLocation: projectLocationSchema, + /** New folder name for the clone (already validated by the renderer). */ + name: z.string().min(1), + source: cloneRepoSourceSchema, +}); +export type CloneRepoPayload = z.infer; diff --git a/src/shared/createProject.test.ts b/src/shared/createProject.test.ts index ed444656..2b63fbad 100644 --- a/src/shared/createProject.test.ts +++ b/src/shared/createProject.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { buildScratchTargetPath, + cloneFolderNameFromRepo, + cloneFolderNameFromUrl, deriveLocationFromPath, parentDirOf, runtimeKeyForChoice, @@ -215,3 +217,35 @@ describe("wslHomeDir", () => { expect(wslHomeDir("Ubuntu")).toBe("\\\\wsl.localhost\\Ubuntu\\home"); }); }); + +describe("cloneFolderNameFromRepo", () => { + test("takes the bare repo name from owner/name", () => { + expect(cloneFolderNameFromRepo("SDSLeon/lightcode")).toBe("lightcode"); + }); + + test("strips a trailing .git", () => { + expect(cloneFolderNameFromRepo("owner/repo.git")).toBe("repo"); + }); + + test("handles a value without an owner", () => { + expect(cloneFolderNameFromRepo("repo")).toBe("repo"); + }); +}); + +describe("cloneFolderNameFromUrl", () => { + test("derives the name from an https URL", () => { + expect(cloneFolderNameFromUrl("https://github.com/owner/repo.git")).toBe("repo"); + }); + + test("derives the name from an scp-style URL", () => { + expect(cloneFolderNameFromUrl("git@github.com:owner/repo.git")).toBe("repo"); + }); + + test("ignores a trailing slash and missing .git", () => { + expect(cloneFolderNameFromUrl("https://github.com/owner/repo/")).toBe("repo"); + }); + + test("returns empty for an empty URL", () => { + expect(cloneFolderNameFromUrl(" ")).toBe(""); + }); +}); diff --git a/src/shared/createProject.ts b/src/shared/createProject.ts index 219cfefa..369e8e1a 100644 --- a/src/shared/createProject.ts +++ b/src/shared/createProject.ts @@ -123,3 +123,24 @@ export function validateScratchParent(parent: string, choice: RuntimeChoice): st export function wslHomeDir(distro: string): string { return toWslUncPath(distro, "home"); } + +/** Default clone folder name from an "owner/name" repo id (the bare repo name). */ +export function cloneFolderNameFromRepo(nameWithOwner: string): string { + const leaf = nameWithOwner.split("/").pop() ?? nameWithOwner; + return leaf.replace(/\.git$/i, ""); +} + +/** + * Default clone folder name from a git URL: the last path segment with any + * trailing `.git` and slashes removed. Handles both https + * (`https://host/owner/repo.git`) and scp-style (`git@host:owner/repo.git`). + */ +export function cloneFolderNameFromUrl(url: string): string { + const trimmed = url + .trim() + .replace(/\.git$/i, "") + .replace(/[/\\]+$/, ""); + if (!trimmed) return ""; + const match = /[^/:\\]+$/.exec(trimmed); + return match ? match[0] : ""; +} diff --git a/src/shared/ipc/procedures/github.ts b/src/shared/ipc/procedures/github.ts index 6bba501a..3f5e2d76 100644 --- a/src/shared/ipc/procedures/github.ts +++ b/src/shared/ipc/procedures/github.ts @@ -1,4 +1,5 @@ import { + cloneRepoPayloadSchema, getGitStatusPayloadSchema, ghClosePrPayloadSchema, ghCreatePrPayloadSchema, @@ -7,7 +8,9 @@ import { ghGetPrDiffPayloadSchema, ghGetPrFilesPayloadSchema, ghGetPrForBranchPayloadSchema, + ghListAccountsPayloadSchema, ghListPrsPayloadSchema, + ghListReposPayloadSchema, ghMarkPrReadyPayloadSchema, ghMergePrPayloadSchema, ghPostPrCommentPayloadSchema, @@ -16,6 +19,8 @@ import { ghUpdatePrBranchPayloadSchema, } from "../../contracts"; import type { + CloneRepoPayload, + CloneRepoResult, GetGitStatusPayload, GhCheckAvailableResult, GhClosePrPayload, @@ -29,8 +34,12 @@ import type { GhGetPrFilesPayload, GhGetPrFilesResult, GhGetPrForBranchPayload, + GhListAccountsPayload, + GhListAccountsResult, GhListPrsPayload, GhListPrsResult, + GhListReposPayload, + GhListReposResult, GhMarkPrReadyPayload, GhMergePrPayload, GhPostPrCommentPayload, @@ -118,4 +127,19 @@ export const githubProcedures = { "supervisor", ghPostPrCommentPayloadSchema, ), + ghListAccounts: definePayloadProcedure( + "ghListAccounts", + "supervisor", + ghListAccountsPayloadSchema, + ), + ghListRepos: definePayloadProcedure( + "ghListRepos", + "supervisor", + ghListReposPayloadSchema, + ), + cloneRepo: definePayloadProcedure( + "cloneRepo", + "supervisor", + cloneRepoPayloadSchema, + ), } as const; diff --git a/src/supervisor/git.ts b/src/supervisor/git.ts index 11ab10ce..dbbe5391 100644 --- a/src/supervisor/git.ts +++ b/src/supervisor/git.ts @@ -19,10 +19,12 @@ import { computeDefaultWorktreePath, execGit, execGitBatchWslBridge, + GIT_CLONE_TIMEOUT, GIT_HOOK_TIMEOUT, getLocationIdentity, ghVersionWslBridge, parseRemoteUrl, + resolveClonedProjectPath, setWslGitBridgeClient, } from "./git/exec"; import { GitMergeService } from "./git/mergeService"; @@ -47,6 +49,7 @@ export { getLocationIdentity, parseRemoteUrl, parseStatusPorcelainV2, + resolveClonedProjectPath, }; export class GitService { @@ -245,6 +248,20 @@ export class GitService { await execGit(location, ["init"]); } + /** + * Clone `url` into a new `name` folder inside `parent`, returning the path of + * the created folder. The clone runs with `parent` as its working directory, + * so `parent` must already exist (the renderer picks an existing folder). + */ + async cloneFromUrl( + parent: ProjectLocation, + name: string, + url: string, + ): Promise<{ path: string }> { + await execGit(parent, ["clone", url, name], { timeout: GIT_CLONE_TIMEOUT }); + return { path: resolveClonedProjectPath(parent, name) }; + } + async getAllDiff(location: ProjectLocation): Promise { return execGit(location, ["diff"]); } diff --git a/src/supervisor/git/exec.ts b/src/supervisor/git/exec.ts index b6f7aa44..20b28d91 100644 --- a/src/supervisor/git/exec.ts +++ b/src/supervisor/git/exec.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { homedir } from "node:os"; -import { dirname, join, normalize, posix } from "node:path"; +import { dirname, join, normalize, posix, win32 } from "node:path"; import { promisify } from "node:util"; import type { GitRemoteInfo, ProjectLocation, RemoteHostPlatform } from "@/shared/contracts"; import { resolveLightcodePaths } from "@/shared/lightcodePaths"; @@ -17,6 +17,9 @@ export const GIT_STATUS_TIMEOUT = 10_000; export const GIT_DIFF_TIMEOUT = 15_000; export const GIT_NETWORK_TIMEOUT = 30_000; export const GIT_DEFAULT_TIMEOUT = 15_000; +// Cloning a repository can pull a lot of history over the network; allow plenty +// of time before giving up so large repos don't fail spuriously. +export const GIT_CLONE_TIMEOUT = 600_000; // Operations that invoke user-defined hooks (pre-commit lint/typecheck/test, etc.). // Generous bound so common hook chains complete; still finite so a hung hook can't pin the UI forever. export const GIT_HOOK_TIMEOUT = 300_000; @@ -261,6 +264,18 @@ function detectPlatform(hostname: string): RemoteHostPlatform { return "unknown"; } +/** + * Path of a child folder (e.g. a freshly cloned repo) inside a parent location. + * Mirrors the join rules of the main process's `createProjectDirectory`: posix + * uses `/`, while windows and WSL clones live at the parent's UNC path joined + * with `\` so the renderer can derive the project location from the result. + */ +export function resolveClonedProjectPath(parent: ProjectLocation, name: string): string { + if (parent.kind === "posix") return posix.join(parent.path, name); + if (parent.kind === "windows") return win32.join(parent.path, name); + return win32.join(parent.uncPath, name); +} + export function parseRemoteUrl(url: string): GitRemoteInfo | null { const httpsMatch = url.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/); if (httpsMatch) { diff --git a/src/supervisor/github.test.ts b/src/supervisor/github.test.ts index 10b0b992..bf7f1e61 100644 --- a/src/supervisor/github.test.ts +++ b/src/supervisor/github.test.ts @@ -44,7 +44,13 @@ vi.mock("./agents/base", () => ({ buildAgentCommand: buildAgentCommandMock, })); -import { GitHubService, aggregateChecksStatus } from "./github"; +import { + GitHubService, + aggregateChecksStatus, + mapGitHubApiRepo, + parseGhAuthAccounts, +} from "./github"; +import { resolveClonedProjectPath } from "./git/exec"; const location = { kind: "windows" as const, path: "C:\\Users\\demo\\repo" }; const wslLocation = { @@ -77,7 +83,13 @@ describe("GitHubService", () => { const result = await new GitHubService().checkGhAvailable(location); expect(result).toEqual({ available: true }); - expect(buildAgentCommandMock).toHaveBeenCalledWith(location, "gh", ["--version"]); + expect(buildAgentCommandMock).toHaveBeenCalledWith( + location, + "gh", + ["--version"], + undefined, + undefined, + ); expect(execFileAsyncMock.mock.calls[0]![2]).toMatchObject({ cwd: location.path }); }); @@ -941,4 +953,214 @@ describe("GitHubService", () => { expect(result.checks).toEqual([]); }); }); + + describe("listAccounts", () => { + const AUTH_STATUS = [ + "github.com", + " ✓ Logged in to github.com account SDSLeon (keyring)", + " - Active account: true", + "", + " ✓ Logged in to github.com account ym-svecherenko (keyring)", + " - Active account: false", + "", + ].join("\n"); + + it("parses signed-in accounts and the active flag", async () => { + execFileAsyncMock.mockResolvedValue({ stdout: AUTH_STATUS }); + + const result = await new GitHubService().listAccounts(location); + + expect(result.accounts).toEqual([ + { host: "github.com", login: "SDSLeon", active: true }, + { host: "github.com", login: "ym-svecherenko", active: false }, + ]); + }); + + it("returns an empty list when gh is signed out", async () => { + execFileAsyncMock.mockRejectedValue(Object.assign(new Error("exit 1"), { stdout: "" })); + + const result = await new GitHubService().listAccounts(location); + + expect(result.accounts).toEqual([]); + }); + + it("still parses accounts when gh exits non-zero but printed them", async () => { + execFileAsyncMock.mockRejectedValue( + Object.assign(new Error("exit 1"), { stdout: AUTH_STATUS }), + ); + + const result = await new GitHubService().listAccounts(location); + + expect(result.accounts).toHaveLength(2); + }); + }); + + describe("listRepos", () => { + it("scopes to the account token and maps the repositories", async () => { + const reposJson = JSON.stringify([ + { + full_name: "yieldmo/web-sdk", + name: "web-sdk", + owner: { login: "yieldmo" }, + description: "SDK", + private: true, + fork: false, + ssh_url: "git@github.com:yieldmo/web-sdk.git", + clone_url: "https://github.com/yieldmo/web-sdk.git", + pushed_at: "2026-06-01T00:00:00Z", + }, + ]); + execFileAsyncMock + .mockResolvedValueOnce({ stdout: "gho_token123\n" }) + .mockResolvedValueOnce({ stdout: reposJson }); + + const result = await new GitHubService().listRepos(location, { + host: "github.com", + login: "ym-svecherenko", + }); + + expect(result.repos).toEqual([ + { + nameWithOwner: "yieldmo/web-sdk", + owner: "yieldmo", + name: "web-sdk", + description: "SDK", + isPrivate: true, + isFork: false, + sshUrl: "git@github.com:yieldmo/web-sdk.git", + httpsUrl: "https://github.com/yieldmo/web-sdk.git", + pushedAt: "2026-06-01T00:00:00Z", + }, + ]); + expect(execFileAsyncMock.mock.calls[0]![1]).toEqual([ + "auth", + "token", + "--hostname", + "github.com", + "--user", + "ym-svecherenko", + ]); + const apiArgs = execFileAsyncMock.mock.calls[1]![1] as string[]; + expect(apiArgs[0]).toBe("api"); + expect(apiArgs[1]).toContain("user/repos"); + }); + + it("throws (instead of falling back to the active account) when the account token is unavailable", async () => { + // `gh auth token --user X` fails → no token to scope with. + execFileAsyncMock.mockRejectedValueOnce(new Error("no token")); + + await expect( + new GitHubService().listRepos(location, { host: "github.com", login: "ghost" }), + ).rejects.toThrow(/ghost/); + // We must not have proceeded to `gh api user/repos` under the active account. + expect(execFileAsyncMock).toHaveBeenCalledTimes(1); + }); + + it("dedupes and stops fetching after a short page", async () => { + const page = (names: string[]) => + JSON.stringify(names.map((n) => ({ full_name: n, name: n.split("/")[1] }))); + execFileAsyncMock + .mockResolvedValueOnce({ stdout: "tok\n" }) + .mockResolvedValueOnce({ stdout: page(["a/one", "a/two", "a/one"]) }); + + const result = await new GitHubService().listRepos(location, { + host: "github.com", + login: "a", + }); + + expect(result.repos.map((r) => r.nameWithOwner)).toEqual(["a/one", "a/two"]); + // auth token + one page; a short page means no further requests. + expect(execFileAsyncMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("cloneRepo", () => { + it("clones via gh repo clone and returns the joined path", async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: "gho_token\n" }) + .mockResolvedValueOnce({ stdout: "" }); + + const result = await new GitHubService().cloneRepo(location, "myrepo", "owner/myrepo", { + host: "github.com", + login: "SDSLeon", + }); + + expect(result).toEqual({ path: "C:\\Users\\demo\\repo\\myrepo" }); + expect(execFileAsyncMock.mock.calls[1]![1]).toEqual([ + "repo", + "clone", + "owner/myrepo", + "myrepo", + ]); + }); + }); +}); + +describe("parseGhAuthAccounts", () => { + it("returns an empty list for empty output", () => { + expect(parseGhAuthAccounts("")).toEqual([]); + }); + + it("marks only the active account", () => { + const out = [ + " ✓ Logged in to github.com account alice (keyring)", + " - Active account: false", + " ✓ Logged in to ghe.example.com account bob (oauth_token)", + " - Active account: true", + ].join("\n"); + + expect(parseGhAuthAccounts(out)).toEqual([ + { host: "github.com", login: "alice", active: false }, + { host: "ghe.example.com", login: "bob", active: true }, + ]); + }); +}); + +describe("mapGitHubApiRepo", () => { + it("falls back to full_name when owner/name fields are missing", () => { + expect(mapGitHubApiRepo({ full_name: "octo/repo" })).toEqual({ + nameWithOwner: "octo/repo", + owner: "octo", + name: "repo", + description: "", + isPrivate: false, + isFork: false, + sshUrl: "", + httpsUrl: "", + pushedAt: "", + }); + }); + + it("returns null without a full_name", () => { + expect(mapGitHubApiRepo({})).toBeNull(); + expect(mapGitHubApiRepo(null)).toBeNull(); + }); +}); + +describe("resolveClonedProjectPath", () => { + it("joins posix parents with /", () => { + expect(resolveClonedProjectPath({ kind: "posix", path: "/home/me/code" }, "repo")).toBe( + "/home/me/code/repo", + ); + }); + + it("joins windows parents with \\", () => { + expect(resolveClonedProjectPath({ kind: "windows", path: "C:\\Users\\me\\code" }, "repo")).toBe( + "C:\\Users\\me\\code\\repo", + ); + }); + + it("joins WSL parents onto the UNC path", () => { + expect( + resolveClonedProjectPath( + { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/me/code", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\me\\code", + }, + "repo", + ), + ).toBe("\\\\wsl.localhost\\Ubuntu\\home\\me\\code\\repo"); + }); }); diff --git a/src/supervisor/github.ts b/src/supervisor/github.ts index 72a02a36..3f47eb8e 100644 --- a/src/supervisor/github.ts +++ b/src/supervisor/github.ts @@ -17,17 +17,31 @@ import { type PrCheck, type PrFile, type PrReviewDecision, + type CloneRepoResult, type GhCheckAvailableResult, type GhGetPrChecksResult, type GhGetPrDetailsResult, type GhGetPrFilesResult, type GhGetPrDiffResult, + type GhListAccountsResult, + type GhListReposResult, + type GitHubAccount, + type GitHubAccountRef, + type GitHubRepoSummary, } from "@/shared/contracts"; import { buildAgentCommand } from "./agents/base"; +import { resolveClonedProjectPath } from "./git/exec"; import type { WslBridgeClient, WslProcessExecResult } from "./wsl/bridge/client"; const execFileAsync = promisify(execFile); const GH_TIMEOUT = 30_000; +// Cloning can pull a lot of history; give it room before timing out. +const GH_CLONE_TIMEOUT = 600_000; +// Repos per page and the page cap for the picker. The list is sorted by most +// recent push, so the first few hundred cover the realistic clone targets; the +// renderer adds client-side search over whatever we return. +const GH_REPO_PAGE_SIZE = 100; +const GH_REPO_MAX_PAGES = 5; const CREATE_PR_STATUS_WAIT_MS = 15_000; const CREATE_PR_STATUS_POLL_MS = 5_000; const PR_VIEW_FIELDS = @@ -93,11 +107,19 @@ function classifyError(error: unknown, operation: string): Error { return new Error(`gh ${operation} failed: ${msg}`); } +interface RunGhOptions { + /** Extra env (e.g. `GH_TOKEN`) merged into the gh invocation. */ + env?: Record; + timeoutMs?: number; +} + async function runGh( location: ProjectLocation, args: string[], wslClient: WslBridgeClient | undefined, + options?: RunGhOptions, ): Promise { + const timeoutMs = options?.timeoutMs ?? GH_TIMEOUT; if (location.kind === "wsl") { if (!wslClient) { throw new Error(`WSL bridge unavailable for GitHub CLI in distro "${location.distro}"`); @@ -107,17 +129,18 @@ async function runGh( cwd: location.linuxPath, args, loginEnv: true, - timeoutMs: GH_TIMEOUT, + timeoutMs, + ...(options?.env ? { env: options.env } : {}), }); if (result.ok) return result.stdout; throw processResultToError(result); } - const spec = buildAgentCommand(location, "gh", args); + const spec = buildAgentCommand(location, "gh", args, undefined, options?.env); const cwd = spec.cwd ?? location.path; const { stdout } = await execFileAsync(spec.command, spec.args, { windowsHide: true, - timeout: GH_TIMEOUT, + timeout: timeoutMs, ...(cwd ? { cwd } : {}), env: spec.env ? { ...process.env, ...spec.env } : process.env, }); @@ -407,6 +430,62 @@ function mapPrDetails(raw: Record): PrDetails { }; } +/** + * Parse the accounts out of `gh auth status` text. The output groups one block + * per signed-in account: a "Logged in to account " line followed + * by an "Active account: true/false" line. Tolerant of formatting drift across + * gh versions — we only key off those two phrases. + */ +export function parseGhAuthAccounts(output: string): GitHubAccount[] { + const accounts: GitHubAccount[] = []; + let current: GitHubAccount | null = null; + for (const line of output.split(/\r?\n/)) { + const loggedIn = /Logged in to (\S+) account (\S+)/.exec(line); + if (loggedIn) { + current = { host: loggedIn[1]!, login: loggedIn[2]!, active: false }; + accounts.push(current); + continue; + } + if (current && /Active account:\s*true/i.test(line)) { + current.active = true; + } + } + return accounts; +} + +/** Map a raw GitHub REST `user/repos` entry to the picker summary. */ +export function mapGitHubApiRepo(raw: unknown): GitHubRepoSummary | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + const nameWithOwner = typeof r.full_name === "string" ? r.full_name : ""; + if (!nameWithOwner) return null; + const [ownerFromFull, nameFromFull] = nameWithOwner.split("/"); + const ownerObj = r.owner as Record | null | undefined; + const owner = + ownerObj && typeof ownerObj.login === "string" ? ownerObj.login : (ownerFromFull ?? ""); + const name = typeof r.name === "string" ? r.name : (nameFromFull ?? ""); + return { + nameWithOwner, + owner, + name, + description: typeof r.description === "string" ? r.description : "", + isPrivate: r.private === true, + isFork: r.fork === true, + sshUrl: typeof r.ssh_url === "string" ? r.ssh_url : "", + httpsUrl: typeof r.clone_url === "string" ? r.clone_url : "", + pushedAt: typeof r.pushed_at === "string" ? r.pushed_at : "", + }; +} + +/** Read `stdout` off a child-process / bridge error, if present. */ +function extractStdout(error: unknown): string { + if (error && typeof error === "object" && "stdout" in error) { + const value = (error as { stdout: unknown }).stdout; + if (typeof value === "string") return value; + } + return ""; +} + /** Stable cache key for {@link GitHubService.viewerLoginCache}. */ function locationKey(location: ProjectLocation): string { if (location.kind === "wsl") return `wsl:${location.distro}:${location.linuxPath}`; @@ -422,8 +501,12 @@ export class GitHubService { this.wslClient = client; } - private runGh(location: ProjectLocation, args: string[]): Promise { - return runGh(location, args, this.wslClient); + private runGh( + location: ProjectLocation, + args: string[], + options?: RunGhOptions, + ): Promise { + return runGh(location, args, this.wslClient, options); } private runGhBatch( @@ -442,6 +525,109 @@ export class GitHubService { } } + /** + * List the accounts `gh` is signed in to. Returns an empty list (rather than + * throwing) when gh is missing or signed out, so the picker can degrade to a + * "sign in / paste a URL" state. gh exits non-zero when any account's token + * is invalid yet still prints the others to stdout, so we parse that too. + */ + async listAccounts(location: ProjectLocation): Promise { + try { + const stdout = await this.runGh(location, ["auth", "status"]); + return { accounts: parseGhAuthAccounts(stdout) }; + } catch (err) { + return { accounts: parseGhAuthAccounts(extractStdout(err)) }; + } + } + + /** + * Resolve the stored token for a specific account so we can scope gh to it + * (every account listed by `gh auth status` has a retrievable token). Throws a + * clear error rather than returning undefined, so callers never silently fall + * back to gh's *active* account — which would list/clone the wrong account. + */ + private async getAccountToken( + location: ProjectLocation, + account: GitHubAccountRef, + ): Promise { + let token = ""; + try { + const stdout = await this.runGh(location, [ + "auth", + "token", + "--hostname", + account.host, + "--user", + account.login, + ]); + token = stdout.trim(); + } catch { + token = ""; + } + if (!token) { + throw new Error( + `Couldn't access the GitHub account "${account.login}". Run "gh auth login" and try again.`, + ); + } + return token; + } + + /** + * List repositories the given account can clone — its own plus org repos — + * most-recently-pushed first. Runs `gh api user/repos` scoped to the account's + * token (via `GH_TOKEN`) so it works for accounts that aren't gh's active one, + * without mutating the user's active account. + */ + async listRepos( + location: ProjectLocation, + account: GitHubAccountRef, + ): Promise { + const token = await this.getAccountToken(location, account); + const env = { GH_TOKEN: token }; + try { + const repos: GitHubRepoSummary[] = []; + const seen = new Set(); + for (let page = 1; page <= GH_REPO_MAX_PAGES; page++) { + const path = + `user/repos?per_page=${GH_REPO_PAGE_SIZE}` + + `&affiliation=owner,collaborator,organization_member&sort=pushed&page=${page}`; + const stdout = await this.runGh(location, ["api", path], { env }); + const items = JSON.parse(stdout); + if (!Array.isArray(items) || items.length === 0) break; + for (const item of items) { + const repo = mapGitHubApiRepo(item); + if (repo && !seen.has(repo.nameWithOwner)) { + seen.add(repo.nameWithOwner); + repos.push(repo); + } + } + if (items.length < GH_REPO_PAGE_SIZE) break; + } + return { repos }; + } catch (err) { + throw classifyError(err, "repo list"); + } + } + + /** Clone a `gh`-browsed repository into `parent/name`, scoped to its account. */ + async cloneRepo( + parent: ProjectLocation, + name: string, + nameWithOwner: string, + account: GitHubAccountRef, + ): Promise { + const token = await this.getAccountToken(parent, account); + try { + await this.runGh(parent, ["repo", "clone", nameWithOwner, name], { + timeoutMs: GH_CLONE_TIMEOUT, + env: { GH_TOKEN: token }, + }); + return { path: resolveClonedProjectPath(parent, name) }; + } catch (err) { + throw classifyError(err, "repo clone"); + } + } + private async getViewerLogin(location: ProjectLocation): Promise { const key = locationKey(location); const cached = this.viewerLoginCache.get(key); diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index df94590a..8ce1ee42 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -111,6 +111,9 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi ghUpdatePrBranch: (payload) => runtime.ghUpdatePrBranch(payload), ghGetPrDetails: (payload) => runtime.ghGetPrDetails(payload), ghPostPrComment: (payload) => runtime.ghPostPrComment(payload), + ghListAccounts: (payload) => runtime.ghListAccounts(payload), + ghListRepos: (payload) => runtime.ghListRepos(payload), + cloneRepo: (payload) => runtime.cloneRepo(payload), lspStart: (payload) => runtime.lspStart(payload), lspStop: (payload) => runtime.lspStop(payload), lspSendMessage: (payload) => runtime.lspSendMessage(payload), diff --git a/src/supervisor/runtime.ts b/src/supervisor/runtime.ts index 542f7ce8..97686476 100644 --- a/src/supervisor/runtime.ts +++ b/src/supervisor/runtime.ts @@ -33,10 +33,16 @@ import type { GetGitBranchesPayload, GetGitDiffBatchPayload, GetGitDiffPayload, + CloneRepoPayload, + CloneRepoResult, GetGitFileContentPayload, GetGitStatusPayload, GhCheckAvailableResult, GhCreatePrPayload, + GhListAccountsPayload, + GhListAccountsResult, + GhListReposPayload, + GhListReposResult, GhGetPrChecksPayload, GhGetPrChecksResult, GhGetPrDetailsPayload, @@ -1185,6 +1191,27 @@ export class SupervisorRuntime { return this.githubService.checkGhAvailable(payload.projectLocation); } + async ghListAccounts(payload: GhListAccountsPayload): Promise { + return this.githubService.listAccounts(payload.runtime); + } + + async ghListRepos(payload: GhListReposPayload): Promise { + return this.githubService.listRepos(payload.runtime, payload.account); + } + + async cloneRepo(payload: CloneRepoPayload): Promise { + const { parentLocation, name, source } = payload; + if (source.kind === "github") { + return this.githubService.cloneRepo( + parentLocation, + name, + source.nameWithOwner, + source.account, + ); + } + return this.gitService.cloneFromUrl(parentLocation, name, source.url); + } + async ghCreatePr(payload: GhCreatePrPayload): Promise { return this.githubService.createPr( payload.projectLocation, diff --git a/src/supervisor/wsl/bridge/bridge.mjs b/src/supervisor/wsl/bridge/bridge.mjs index f19eea07..6f991670 100644 --- a/src/supervisor/wsl/bridge/bridge.mjs +++ b/src/supervisor/wsl/bridge/bridge.mjs @@ -86,7 +86,10 @@ const MAX_FS_BODY_BYTES = 2 * 1024 * 1024; const MAX_MCP_BODY_BYTES = 1024 * 1024; const MAX_FIND_ENTRIES = 50_000; const MAX_GIT_COMMANDS = 256; -const MAX_GIT_TIMEOUT_MS = 300_000; +// Generous upper bound so long network operations (notably `git clone` / `gh +// repo clone` of large repos) can run to completion; still finite so a hung +// command can't pin the bridge forever. +const MAX_GIT_TIMEOUT_MS = 600_000; const VALID_INTENTS = new Set([ "session.started", "session.turn_started", diff --git a/src/supervisor/wsl/bridge/client.test.ts b/src/supervisor/wsl/bridge/client.test.ts index f8ba2ee5..b2018f81 100644 --- a/src/supervisor/wsl/bridge/client.test.ts +++ b/src/supervisor/wsl/bridge/client.test.ts @@ -1,8 +1,32 @@ import { createServer, type Server } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WslBridgeClient, type WslLocation } from "./client"; +import { WslBridgeClient, bridgeRequestTimeoutMs, type WslLocation } from "./client"; import type { WslBridgeServer } from "./index"; +describe("bridgeRequestTimeoutMs", () => { + it("uses the default for bodies without a timeout", () => { + expect(bridgeRequestTimeoutMs({})).toBe(30_000); + expect(bridgeRequestTimeoutMs({ cwd: "/x" })).toBe(30_000); + expect(bridgeRequestTimeoutMs(undefined)).toBe(30_000); + }); + + it("keeps the default floor when the requested timeout is small", () => { + // 10s request + 15s margin = 25s, below the 30s floor. + expect(bridgeRequestTimeoutMs({ timeoutMs: 10_000 })).toBe(30_000); + }); + + it("outlasts a long requested timeout (e.g. clone) so the server timeout wins", () => { + // A 600s clone must not be aborted by the 30s HTTP default. + expect(bridgeRequestTimeoutMs({ timeoutMs: 600_000 })).toBe(615_000); + }); + + it("ignores invalid timeout values", () => { + expect(bridgeRequestTimeoutMs({ timeoutMs: -5 })).toBe(30_000); + expect(bridgeRequestTimeoutMs({ timeoutMs: Number.NaN })).toBe(30_000); + expect(bridgeRequestTimeoutMs({ timeoutMs: "600000" })).toBe(30_000); + }); +}); + /** * Client-side tests: stand up a real HTTP server in-process, wire it into a * fake `WslBridgeServer` via `ensureBridge()`, and assert the client diff --git a/src/supervisor/wsl/bridge/client.ts b/src/supervisor/wsl/bridge/client.ts index 61dd5b29..dd709154 100644 --- a/src/supervisor/wsl/bridge/client.ts +++ b/src/supervisor/wsl/bridge/client.ts @@ -3,8 +3,27 @@ import type { ProjectLocation } from "@/shared/contracts"; import type { WslBridgeServer, WatchEvent, WatchScope } from "./index"; const BRIDGE_REQUEST_TIMEOUT_MS = 30_000; +// For long-running commands (e.g. `git clone`) the request carries its own +// `timeoutMs`; the HTTP abort must outlast it so the server-side timeout — which +// returns a proper result — wins instead of the client aborting prematurely. +const BRIDGE_REQUEST_TIMEOUT_MARGIN_MS = 15_000; const BRIDGE_FETCH_ATTEMPTS = 8; +/** + * How long to let a single bridge HTTP request run before aborting. Commands + * that pass a `timeoutMs` (git/process exec + batches) get that plus a margin so + * the in-distro server's own timeout fires first; everything else uses the + * default. A stuck server is still bounded. + */ +export function bridgeRequestTimeoutMs(body: unknown): number { + const requested = + body && typeof body === "object" ? (body as { timeoutMs?: unknown }).timeoutMs : undefined; + if (typeof requested === "number" && Number.isFinite(requested) && requested > 0) { + return Math.max(BRIDGE_REQUEST_TIMEOUT_MS, requested + BRIDGE_REQUEST_TIMEOUT_MARGIN_MS); + } + return BRIDGE_REQUEST_TIMEOUT_MS; +} + /** * Thin, typed façade over the in-distro bridge server's `/v1/fs/*` endpoints. * Every method ensures the bridge is running (lazy spawn), then issues one @@ -241,7 +260,12 @@ export class WslBridgeClient { if (!handle) { throw asNodeErr("EUNAVAIL", `WSL bridge unavailable for distro ${location.distro}`); } - const response = await fetchBridge(`${handle.baseUrl}${path}`, handle.secret, body); + const response = await fetchBridge( + `${handle.baseUrl}${path}`, + handle.secret, + body, + bridgeRequestTimeoutMs(body), + ); let parsed: unknown; try { parsed = await response.json(); @@ -265,11 +289,16 @@ export class WslBridgeClient { } } -async function fetchBridge(url: string, secret: string, body: unknown): Promise { +async function fetchBridge( + url: string, + secret: string, + body: unknown, + requestTimeoutMs: number = BRIDGE_REQUEST_TIMEOUT_MS, +): Promise { let lastError: unknown; for (let attempt = 1; attempt <= BRIDGE_FETCH_ATTEMPTS; attempt += 1) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), BRIDGE_REQUEST_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); if (typeof timeout.unref === "function") timeout.unref(); try { return await fetch(url, { From 3ec33b13d8f16aafa06672f4c077078f21d58f85 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 13 Jun 2026 22:47:22 -0700 Subject: [PATCH 2/3] fix(clone-project-modal): improve loading states - Show a cloning progress view with target and destination details - Update repo browser loading, search, and row states - Cover the in-flight clone flow with a modal test --- .../CreateProject/CloneProjectModal.test.tsx | 25 +++++++ .../parts/CreateProject/CloneProjectModal.tsx | 65 ++++++++++++++----- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx index 7efce8f6..0b60fe98 100644 --- a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx +++ b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.test.tsx @@ -126,6 +126,31 @@ describe("CloneProjectModal", () => { test("falls back to URL mode when no GitHub accounts are signed in", async () => { mocks.ghListAccounts.mockResolvedValue({ accounts: [] }); + test("shows a loading view with the target while the clone is in flight", async () => { + let resolveClone: () => void = () => {}; + mocks.commitCloneProject.mockReturnValue( + new Promise((resolve) => { + resolveClone = resolve; + }), + ); + + usePanelStore.getState().openCloneProjectModal(); + render(); + + fireEvent.click(await screen.findByText("SDSLeon/lightcode")); + fireEvent.click(await screen.findByRole("button", { name: "Clone" })); + + // The form is replaced by a loading view naming what's being cloned. + await waitFor(() => + expect(screen.getByText(/Cloning SDSLeon\/lightcode/)).toBeInTheDocument(), + ); + expect(screen.getByRole("button", { name: "Cloning…" })).toBeDisabled(); + expect(screen.queryByLabelText("Folder name")).not.toBeInTheDocument(); + + resolveClone(); + await waitFor(() => expect(usePanelStore.getState().cloneProjectModalOpen).toBe(false)); + }); + usePanelStore.getState().openCloneProjectModal(); render(); diff --git a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx index d2dcefd0..44cc7c01 100644 --- a/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx +++ b/src/renderer/views/MainView/parts/CreateProject/CloneProjectModal.tsx @@ -20,7 +20,7 @@ import { } from "@/renderer/actions/createProjectActions"; import { usePanelStore } from "@/renderer/state/panelStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; -import { Input, TuxIcon } from "@/renderer/components/common"; +import { Input, PixelLoader, TuxIcon } from "@/renderer/components/common"; import { formatRelativeTime } from "@/renderer/utils/formatTime"; type CloneMode = "github" | "url"; @@ -263,6 +263,33 @@ function CloneProjectForm() { setBusy(false); } } + const cloneTarget = + mode === "github" ? (selectedRepo?.nameWithOwner ?? "repository") : url.trim() || "repository"; + + if (busy) { + return ( + <> + + Cloning… + + + +
+

Cloning {cloneTarget}

+

+ Downloading into “{name || "the chosen folder"}”. This can take a moment for large + repositories. +

+
+
+ + + + + ); + } const runtimeLabel = runtimeKey === "native" ? "Native" : runtimeKey; @@ -396,8 +423,7 @@ function CloneProjectForm() {