diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8e88420a2..cd7aa051d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -25,12 +25,13 @@ import { type UpdateSettings, type UpdateStatus, } from "./main/update-settings"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; +import { readdir, readFile, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch"; import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; @@ -49,6 +50,7 @@ import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link"; import { shouldLinkOnAttach } from "./main/daemon-owner"; import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state"; +import type { WorkspaceRepoScanItem } from "./preload"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -87,6 +89,8 @@ let browserViewHost: BrowserViewHost | null = null; // Held for the app lifetime. Dropping it (on any exit) triggers daemon self-stop. let supervisorLink: SupervisorLinkHandle | null = null; +const execFileAsync = promisify(execFile); + const isDev = !app.isPackaged; const RENDERER_SCHEME = "app"; @@ -263,6 +267,54 @@ function telemetryOverrides(): Record { }; } +async function gitOutput(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { timeout: 5000 }); + return String(stdout).trim(); + } catch { + return null; + } +} + +async function isStandaloneGitRepo(repoPath: string): Promise { + try { + const gitStat = await stat(path.join(repoPath, ".git")); + if (!gitStat.isDirectory()) return false; + } catch { + return false; + } + const topLevel = await gitOutput(repoPath, ["rev-parse", "--show-toplevel"]); + return topLevel !== null && path.resolve(topLevel) === path.resolve(repoPath); +} + +async function scanWorkspaceRepos(parent: string): Promise { + const entries = await readdir(parent, { withFileTypes: true }); + const repos = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name !== ".git") + .map(async (entry): Promise => { + const repoPath = path.join(parent, entry.name); + if (!(await isStandaloneGitRepo(repoPath))) return null; + + const [branch, remote] = await Promise.all([ + gitOutput(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]), + gitOutput(repoPath, ["remote", "get-url", "origin"]), + ]); + return { + name: entry.name, + path: repoPath, + branch, + remote, + valid: remote !== null && remote !== "", + error: remote ? undefined : "No remote configured", + }; + }), + ); + return repos + .filter((repo): repo is WorkspaceRepoScanItem => repo !== null) + .sort((a, b) => a.name.localeCompare(b.name)); +} + // Run the user's login shell to dump its env. stdin is ignored so an rc that // reads input hits EOF instead of hanging; stderr is ignored to drop banner // noise. Never rejects: resolves null on spawn error, non-zero exit, or timeout @@ -793,13 +845,16 @@ ipcMain.handle("telemetry:getBootstrap", () => ipcMain.handle("app:chooseDirectory", async () => { const options: OpenDialogOptions = { properties: ["openDirectory"], - title: "Choose a git repository", + title: "Choose a folder", }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); if (result.canceled) return null; return result.filePaths[0] ?? null; }); +ipcMain.handle("app:scanWorkspaceRepos", async (_event, selectedPath: string): Promise => { + return scanWorkspaceRepos(selectedPath); +}); ipcMain.handle("clipboard:writeText", (_event, text: string) => { clipboard.writeText(text, "clipboard"); if (process.platform === "linux") { diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 2284e6c44..c5954ad5d 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -16,10 +16,21 @@ export type BrowserNavigateInput = { url: string; }; +export type WorkspaceRepoScanItem = { + name: string; + path: string; + branch: string | null; + remote: string | null; + valid: boolean; + error?: string; +}; + const api = { app: { getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise, chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise, + scanWorkspaceRepos: (path: string) => + ipcRenderer.invoke("app:scanWorkspaceRepos", path) as Promise, }, clipboard: { writeText: (text: string) => ipcRenderer.invoke("clipboard:writeText", text) as Promise, diff --git a/frontend/src/renderer/components/ImportProjectDialog.tsx b/frontend/src/renderer/components/ImportProjectDialog.tsx new file mode 100644 index 000000000..739bd2c6c --- /dev/null +++ b/frontend/src/renderer/components/ImportProjectDialog.tsx @@ -0,0 +1,417 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { ArrowLeft, Check, Folder, FolderPlus, GitBranch, Loader2, X } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import type { WorkspaceRepoScanItem } from "../../preload"; +import { apiErrorMessage } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet"; + +export type ImportProjectInput = { + path: string; + workerAgent?: string; + orchestratorAgent?: string; + asWorkspace?: boolean; +}; + +type ImportProjectDialogProps = { + children: (state: { open: () => void; disabled: boolean; label: string }) => ReactNode; + onImport: (input: ImportProjectInput) => Promise; +}; + +type ImportMode = "workspace" | "project"; +type Step = "type" | "workspace" | "project"; + +export function ImportProjectDialog({ children, onImport }: ImportProjectDialogProps) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState("type"); + const [selectedPath, setSelectedPath] = useState(null); + const [repos, setRepos] = useState([]); + const [error, setError] = useState(null); + const [isChoosing, setIsChoosing] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [agentSheetOpen, setAgentSheetOpen] = useState(false); + + const reset = () => { + setStep("type"); + setSelectedPath(null); + setRepos([]); + setError(null); + setIsChoosing(false); + setIsImporting(false); + setAgentSheetOpen(false); + }; + + const close = () => { + if (isChoosing || isImporting) return; + setOpen(false); + reset(); + }; + + const chooseFolder = async (mode: ImportMode) => { + setError(null); + setIsChoosing(true); + try { + const path = await aoBridge.app.chooseDirectory(); + if (!path) return; + setSelectedPath(path); + if (mode === "workspace") { + setRepos(await aoBridge.app.scanWorkspaceRepos(path)); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Could not inspect folder"); + } finally { + setIsChoosing(false); + } + }; + + const importWorkspace = async () => { + if (!selectedPath || repos.length === 0 || repos.some((repo) => !repo.valid)) return; + setError(null); + setIsImporting(true); + try { + await onImport({ path: selectedPath, asWorkspace: true }); + close(); + } catch (err) { + setError(apiErrorMessage(err, "Could not import workspace")); + } finally { + setIsImporting(false); + } + }; + + const importProject = async (selection: CreateProjectAgentSelection) => { + if (!selectedPath) return; + setError(null); + setIsImporting(true); + try { + await onImport({ path: selectedPath, ...selection }); + close(); + } catch (err) { + setError(apiErrorMessage(err, "Could not import project")); + } finally { + setIsImporting(false); + } + }; + + const openDialog = () => { + reset(); + setOpen(true); + }; + + return ( + <> + {children({ open: openDialog, disabled: isChoosing || isImporting, label: isChoosing ? "Opening..." : "New project" })} + (next ? setOpen(true) : close())}> + + + + {step === "type" ? ( + setStep(mode)} /> + ) : ( + { + setError(null); + setSelectedPath(null); + setRepos([]); + setStep("type"); + }} + onCancel={close} + onChoose={() => void chooseFolder(step)} + onImportProject={() => setAgentSheetOpen(true)} + onImportWorkspace={() => void importWorkspace()} + repos={repos} + selectedPath={selectedPath} + /> + )} + + + + + + ); +} + +function TypePicker({ onSelect }: { onSelect: (mode: ImportMode) => void }) { + return ( +
+ Import to Agent Orchestrator + + Choose whether to import a workspace folder or a single project repository. + +
+ } + onClick={() => onSelect("workspace")} + tag="• Multiple repositories" + title="Workspace" + /> + } + onClick={() => onSelect("project")} + tag="• One repository" + title="Project" + /> +
+
+ ); +} + +function TypeCard({ + description, + icon, + onClick, + tag, + title, +}: { + description: string; + icon: ReactNode; + onClick: () => void; + tag: string; + title: string; +}) { + return ( + + ); +} + +function WorkspacePreview() { + return ( +
+
+
+
+ {["api", "web", "workers"].map((name) => ( +
+
+ ))} +
+
+ ); +} + +function ProjectPreview() { + return ( +
+
+
+
+ ); +} + +function ImportFolderStep({ + error, + isChoosing, + isImporting, + mode, + onBack, + onCancel, + onChoose, + onImportProject, + onImportWorkspace, + repos, + selectedPath, +}: { + error: string | null; + isChoosing: boolean; + isImporting: boolean; + mode: "workspace" | "project"; + onBack: () => void; + onCancel: () => void; + onChoose: () => void; + onImportProject: () => void; + onImportWorkspace: () => void; + repos: WorkspaceRepoScanItem[]; + selectedPath: string | null; +}) { + const isWorkspace = mode === "workspace"; + const failedCount = repos.filter((repo) => !repo.valid).length; + const canImportWorkspace = isWorkspace && selectedPath !== null && repos.length > 0 && failedCount === 0 && !isImporting; + const canImportProject = !isWorkspace && selectedPath !== null && !isImporting; + + return ( +
+
+ +
+ + {isWorkspace ? "Import workspace" : "Import project"} + + + {isWorkspace + ? "Pick a folder that contains your Git repositories." + : "Pick a folder that contains your Git repository."} + +
+
+
+ {selectedPath ? ( + + ) : ( + + )} + {error && ( +
+ {error} +
+ )} +
+
+
+ {isWorkspace + ? selectedPath && failedCount > 0 + ? `Resolve ${failedCount} failed ${failedCount === 1 ? "repository" : "repositories"} to continue` + : repos.length === 0 + ? "No repositories to import" + : `${repos.length} repositories ready` + : ""} +
+
+ + +
+
+
+ ); +} + +function SelectedFolder({ + failedCount, + mode, + onChoose, + repos, + selectedPath, +}: { + failedCount: number; + mode: "workspace" | "project"; + onChoose: () => void; + repos: WorkspaceRepoScanItem[]; + selectedPath: string; +}) { + return ( +
+
+
+
Selected folder
+
{selectedPath}
+
+ +
+ {mode === "workspace" && failedCount > 0 && ( +
+ VALIDATION FAILED · WORKSPACE NOT REGISTERED +
+ )} + {mode === "workspace" && ( +
+ {repos.length === 0 ? ( +
No repositories found in this folder.
+ ) : ( + repos.map((repo) => ) + )} +
+ )} +
+ ); +} + +function RepoRow({ repo }: { repo: WorkspaceRepoScanItem }) { + const host = repo.remote ? remoteHost(repo.remote) : "No remote configured"; + return ( +
+
+ {repo.valid ?
+
+
{repo.name}
+
{repo.path}
+
+
+ {repo.valid ? `${repo.branch ?? "main"} ${host}` : repo.error} +
+
+ ); +} + +function remoteHost(remote: string): string { + const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) return `${sshMatch[1]}/${sshMatch[2].replace(/\.git$/, "")}`; + try { + const url = new URL(remote); + return `${url.host}${url.pathname.replace(/\.git$/, "")}`; + } catch { + return remote.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + } +} diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index 85cd3f99b..588bbc57c 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -29,14 +29,19 @@ const workspace: WorkspaceSummary = { sessions: [], }; -type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise; +type ImportProjectHandler = (input: { + path: string; + workerAgent?: string; + orchestratorAgent?: string; + asWorkspace?: boolean; +}) => Promise; type RemoveProjectHandler = (projectId: string) => Promise; function renderSidebar({ - onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler, + onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler, onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler, }: { - onCreateProject?: CreateProjectHandler; + onCreateProject?: ImportProjectHandler; onRemoveProject?: RemoveProjectHandler; } = {}) { const queryClient = new QueryClient({ @@ -117,13 +122,16 @@ describe("Sidebar", () => { it("requires explicit worker and orchestrator agents when creating a project", async () => { const user = userEvent.setup(); - const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler; window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); renderSidebar({ onCreateProject }); await user.click(screen.getByLabelText("New project")); + await user.click(await screen.findByRole("button", { name: /Project A single Git repository/i })); + await user.click(screen.getByRole("button", { name: /Choose a project folder/i })); + await user.click(await screen.findByRole("button", { name: "Import project" })); - expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); + expect((await screen.findAllByText("/repo/new-project")).length).toBeGreaterThan(0); const dialog = screen.getByRole("dialog", { name: "Project agents" }); expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2"); await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex"); @@ -139,6 +147,58 @@ describe("Sidebar", () => { ); }); + it("imports a valid workspace without opening the agent sheet", async () => { + const user = userEvent.setup(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace"); + window.ao!.app.scanWorkspaceRepos = vi.fn().mockResolvedValue([ + { + name: "web", + path: "/repo/workspace/web", + branch: "main", + remote: "https://github.com/org/web.git", + valid: true, + }, + ]); + renderSidebar({ onCreateProject }); + + await user.click(screen.getByLabelText("New project")); + await user.click(await screen.findByRole("button", { name: /Workspace Several Git repos/i })); + await user.click(screen.getByRole("button", { name: /Choose a folder/i })); + + expect(await screen.findByText("web")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Import workspace" })); + + await waitFor(() => expect(onCreateProject).toHaveBeenCalledWith({ path: "/repo/workspace", asWorkspace: true })); + expect(screen.queryByRole("dialog", { name: "Project agents" })).not.toBeInTheDocument(); + }); + + it("blocks workspace import when a discovered repository has no remote", async () => { + const user = userEvent.setup(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as ImportProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/workspace"); + window.ao!.app.scanWorkspaceRepos = vi.fn().mockResolvedValue([ + { + name: "web", + path: "/repo/workspace/web", + branch: "main", + remote: null, + valid: false, + error: "No remote configured", + }, + ]); + renderSidebar({ onCreateProject }); + + await user.click(screen.getByLabelText("New project")); + await user.click(await screen.findByRole("button", { name: /Workspace Several Git repos/i })); + await user.click(screen.getByRole("button", { name: /Choose a folder/i })); + + expect(await screen.findByText("VALIDATION FAILED · WORKSPACE NOT REGISTERED")).toBeInTheDocument(); + expect(screen.getByText("Resolve 1 failed repository to continue")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Import workspace" })).toBeDisabled(); + expect(onCreateProject).not.toHaveBeenCalled(); + }); + it("opens global settings from the footer menu when no project is selected", async () => { const user = userEvent.setup(); renderSidebar(); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index d9620ff9c..9e6f614c9 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -12,7 +12,7 @@ import { Sun, Trash2, } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { attentionZone, isOrchestratorSession, @@ -21,7 +21,6 @@ import { type WorkspaceSummary, workerSessions, } from "../types/workspace"; -import { aoBridge } from "../lib/bridge"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { useEventsConnection } from "../hooks/useEventsConnection"; @@ -55,7 +54,7 @@ import { OrchestratorIcon } from "./icons"; import aoLogo from "../assets/ao-logo.png"; import { cn } from "../lib/utils"; import { useUiStore } from "../stores/ui-store"; -import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet"; +import { ImportProjectDialog, type ImportProjectInput } from "./ImportProjectDialog"; // The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live // in the full-width topbar's left inset (_shell renders the bar above the @@ -75,7 +74,7 @@ type SidebarProps = { underTopbar?: boolean; workspaceError?: string; workspaces: WorkspaceSummary[]; - onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise; + onCreateProject: (input: ImportProjectInput) => Promise; onRemoveProject: (projectId: string) => Promise; }; @@ -598,15 +597,15 @@ function ProjectItem({ function CreateProjectButton({ onCreateProject }: Pick) { return ( - - {({ disabled, choosePath, label }) => ( + + {({ disabled, open, label }) => (