From 454743472c73ee0fff83edfd2a64a5901ca07b77 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 4 May 2026 17:02:23 -0400 Subject: [PATCH 1/4] Add Project flow Adds end-to-end "Add Project" surface: chooser + create/clone forms reachable from command palette and topbar, project scaffold service for local create and remote clone, and a publish-to-GitHub dialog backed by createRepository + publishCurrentProject in githubService. Supporting IPC, preload bridges, and shared types follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 1 + apps/desktop/src/main/main.ts | 23 +- .../services/github/githubService.test.ts | 205 +++ .../src/main/services/github/githubService.ts | 84 +- .../src/main/services/ipc/registerIpc.ts | 100 ++ .../projects/adeProjectService.test.ts | 76 -- .../projects/projectBrowserService.test.ts | 104 -- .../projects/projectDetailService.test.ts | 91 -- .../projects/projectLifecycle.test.ts | 448 +++++++ .../projects/projectScaffoldService.test.ts | 489 ++++++++ .../projects/projectScaffoldService.ts | 271 ++++ .../projects/recentProjectSummary.test.ts | 179 --- apps/desktop/src/preload/global.d.ts | 13 + apps/desktop/src/preload/preload.ts | 18 + .../components/app/CommandPalette.tsx | 305 ++++- .../src/renderer/components/app/TopBar.tsx | 63 +- .../components/projects/AddProjectChooser.tsx | 187 +++ .../components/projects/CloneProjectForm.tsx | 1102 +++++++++++++++++ .../components/projects/CreateProjectForm.tsx | 352 ++++++ .../projects/ProjectActionSuccess.tsx | 95 ++ .../projects/PublishToGitHubDialog.tsx | 755 +++++++++++ .../src/renderer/components/run/RunPage.tsx | 8 +- .../renderer/lib/useGithubProjectRemote.ts | 60 + apps/desktop/src/shared/ipc.ts | 5 + apps/desktop/src/shared/types/core.ts | 50 + docs/features/project-home/README.md | 92 +- 26 files changed, 4666 insertions(+), 510 deletions(-) delete mode 100644 apps/desktop/src/main/services/projects/adeProjectService.test.ts delete mode 100644 apps/desktop/src/main/services/projects/projectBrowserService.test.ts delete mode 100644 apps/desktop/src/main/services/projects/projectDetailService.test.ts create mode 100644 apps/desktop/src/main/services/projects/projectLifecycle.test.ts create mode 100644 apps/desktop/src/main/services/projects/projectScaffoldService.test.ts create mode 100644 apps/desktop/src/main/services/projects/projectScaffoldService.ts delete mode 100644 apps/desktop/src/main/services/projects/recentProjectSummary.test.ts create mode 100644 apps/desktop/src/renderer/components/projects/AddProjectChooser.tsx create mode 100644 apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx create mode 100644 apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx create mode 100644 apps/desktop/src/renderer/components/projects/ProjectActionSuccess.tsx create mode 100644 apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx create mode 100644 apps/desktop/src/renderer/lib/useGithubProjectRemote.ts diff --git a/AGENTS.md b/AGENTS.md index 1a24fb6c8..69f02b62d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - `npm --prefix apps/ade-cli run test` - `npm --prefix apps/ade-cli run build` - Run the smallest relevant subset first when iterating, then finish with the broader checks that cover the touched surfaces. +- If even running the full desktop test suit, u ahve to shard like ci ## Terminology diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 70817daaa..595cefb10 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -40,6 +40,7 @@ import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./s import { createAgentChatService } from "./services/chat/agentChatService"; import { shutdownAcpCliConnections } from "./services/chat/acpCliPool"; import { createGithubService } from "./services/github/githubService"; +import { createProjectScaffoldService } from "./services/projects/projectScaffoldService"; import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService"; import { createPrService } from "./services/prs/prService"; import { createPrPollingService } from "./services/prs/prPollingService"; @@ -1666,6 +1667,11 @@ app.whenReady().then(async () => { appDataDir: app.getPath("userData"), }); + const projectScaffoldService = createProjectScaffoldService({ + logger, + githubService, + }); + const feedbackReporterService = createFeedbackReporterService({ db, logger, @@ -3800,6 +3806,7 @@ app.whenReady().then(async () => { conflictService, aiIntegrationService, githubService, + projectScaffoldService, feedbackReporterService, prService, prPollingService, @@ -3876,6 +3883,19 @@ app.whenReady().then(async () => { const logger = createFileLogger( path.join(app.getPath("userData"), "ade-idle.jsonl"), ); + // Welcome-screen IPCs (project create/clone, listMyRepos) need scaffold + + // github services even before a project is opened. Build minimal versions + // here that share the user-data token store. detectRepo / publishCurrent + // require an active project and will throw clearly when called dormant. + const dormantGithubService = createGithubService({ + logger, + projectRoot: normalizedRoot, + appDataDir: app.getPath("userData"), + }); + const dormantProjectScaffoldService = createProjectScaffoldService({ + logger, + githubService: dormantGithubService, + }); return { db: null, logger, @@ -3915,7 +3935,8 @@ app.whenReady().then(async () => { iosSimulatorService: null, appControlService: null, builtInBrowserService: null, - githubService: null, + githubService: dormantGithubService, + projectScaffoldService: dormantProjectScaffoldService, feedbackReporterService: null, prService: null, prPollingService: null, diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 4b2f47dc6..375ea7404 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -571,3 +571,208 @@ describe("githubService.getStatus", () => { expect(status.repoAccessError).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// createRepository — POST /user/repos +// --------------------------------------------------------------------------- + +describe("githubService.createRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "ghp_env_token"; + }); + + function lastFetchCall() { + const calls = mockFetch.mock.calls; + return calls[calls.length - 1] as [string, RequestInit]; + } + + it("POSTs the canonical body shape and parses the response", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + clone_url: "https://github.com/alice/test.git", + ssh_url: "git@github.com:alice/test.git", + html_url: "https://github.com/alice/test", + default_branch: "main", + }), + ); + + const result = await makeService().createRepository({ + name: "test", + description: "hello world", + isPrivate: true, + }); + + const [url, init] = lastFetchCall(); + expect(url).toContain("/user/repos"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + name: "test", + description: "hello world", + private: true, + auto_init: false, + }); + expect(result).toEqual({ + cloneUrl: "https://github.com/alice/test.git", + sshUrl: "git@github.com:alice/test.git", + htmlUrl: "https://github.com/alice/test", + defaultBranch: "main", + }); + }); + + it("omits the description field when blank", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + clone_url: "https://github.com/alice/test.git", + ssh_url: "git@github.com:alice/test.git", + html_url: "https://github.com/alice/test", + default_branch: "main", + }), + ); + + await makeService().createRepository({ name: "test", isPrivate: false }); + + const [, init] = lastFetchCall(); + const body = JSON.parse(init.body as string); + expect(body).not.toHaveProperty("description"); + expect(body.private).toBe(false); + }); + + it("propagates GitHub error messages on 4xx", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(422, { + message: "Validation Failed", + errors: [{ message: "name already exists on this account" }], + }), + ); + + await expect( + makeService().createRepository({ name: "existing", isPrivate: true }), + ).rejects.toThrow(/validation failed.*already exists/i); + }); +}); + +// --------------------------------------------------------------------------- +// publishCurrentProject — orchestrates createRepo + remote add + push +// --------------------------------------------------------------------------- + +describe("githubService.publishCurrentProject", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "ghp_env_token"; + }); + + it("returns state=pushed when HEAD exists, after creating the repo and pushing", async () => { + runGitMock + // get-url origin: no remote yet + .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "fatal: No such remote 'origin'" }) + // remote add origin: ok + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) + // rev-parse HEAD: ok (commit exists) + .mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }) + // push -u origin HEAD: ok + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); + + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + clone_url: "https://github.com/alice/proj.git", + ssh_url: "git@github.com:alice/proj.git", + html_url: "https://github.com/alice/proj", + default_branch: "main", + }), + ); + + const result = await makeService().publishCurrentProject({ + name: "proj", + isPrivate: true, + }); + + expect(result).toEqual({ state: "pushed", htmlUrl: "https://github.com/alice/proj" }); + const gitCalls = runGitMock.mock.calls.map((c) => c[0]); + expect(gitCalls[0]).toEqual(["remote", "get-url", "origin"]); + expect(gitCalls[1]).toEqual(["remote", "add", "origin", "https://github.com/alice/proj.git"]); + expect(gitCalls[2]).toEqual(["rev-parse", "--verify", "HEAD"]); + expect(gitCalls[3]).toEqual(["push", "-u", "origin", "HEAD"]); + }); + + it("returns state=remote_added when the project has no commits yet", async () => { + runGitMock + .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) // get-url origin + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) // remote add + .mockResolvedValueOnce({ exitCode: 128, stdout: "", stderr: "fatal: Needed a single revision" }); // rev-parse HEAD fails + + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + clone_url: "https://github.com/alice/empty.git", + ssh_url: "git@github.com:alice/empty.git", + html_url: "https://github.com/alice/empty", + default_branch: "main", + }), + ); + + const result = await makeService().publishCurrentProject({ + name: "empty", + isPrivate: true, + }); + + expect(result).toEqual({ state: "remote_added", htmlUrl: "https://github.com/alice/empty" }); + // Should NOT have called push + expect(runGitMock.mock.calls.map((c) => (c[0] as string[])[0])).not.toContain("push"); + }); + + it("throws remote_already_exists when origin is already configured", async () => { + runGitMock.mockResolvedValueOnce({ + exitCode: 0, + stdout: "git@github.com:someone/already.git\n", + stderr: "", + }); + + let caught: any; + try { + await makeService().publishCurrentProject({ name: "x", isPrivate: true }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect(caught.code).toBe("remote_already_exists"); + // Must NOT have hit the API + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws github_not_connected when no token is stored", async () => { + delete process.env.GITHUB_TOKEN; + delete process.env.ADE_GITHUB_TOKEN; + + let caught: any; + try { + await makeService().publishCurrentProject({ name: "x", isPrivate: true }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect(caught.code).toBe("github_not_connected"); + expect(runGitMock).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("surfaces a clear error when the push step fails", async () => { + runGitMock + .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 0, stdout: "abc\n", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "Authentication failed" }); + + mockFetch.mockResolvedValueOnce( + jsonResponse(201, { + clone_url: "https://github.com/alice/proj.git", + ssh_url: "git@github.com:alice/proj.git", + html_url: "https://github.com/alice/proj", + default_branch: "main", + }), + ); + + await expect( + makeService().publishCurrentProject({ name: "proj", isPrivate: true }), + ).rejects.toThrow(/authentication failed/i); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index db62f62ea..00a6052f3 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -17,7 +17,7 @@ function detectGitHubTokenType(token: string): GitHubStatus["tokenType"] { return "unknown"; } -function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | null { +export function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | null { const remoteUrl = remoteUrlRaw.trim(); if (!remoteUrl) return null; @@ -48,7 +48,7 @@ function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | nul return null; } -function parseNextLink(linkHeader: string | null): string | null { +export function parseNextLink(linkHeader: string | null): string | null { if (!linkHeader) return null; for (const part of linkHeader.split(",")) { const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); @@ -644,6 +644,80 @@ export function createGithubService({ return data ?? null; }; + const createRepository = async (args: { + name: string; + description?: string; + isPrivate: boolean; + }): Promise<{ cloneUrl: string; sshUrl: string; htmlUrl: string; defaultBranch: string }> => { + const body: Record = { + name: args.name, + private: args.isPrivate, + auto_init: false, + }; + if (args.description != null && args.description.trim().length > 0) { + body.description = args.description.trim(); + } + const { data } = await apiRequest>({ + method: "POST", + path: "/user/repos", + body, + }); + return { + cloneUrl: asString(data.clone_url), + sshUrl: asString(data.ssh_url), + htmlUrl: asString(data.html_url), + defaultBranch: asString(data.default_branch) || "main", + }; + }; + + const publishCurrentProject = async ( + args: { name: string; description?: string; isPrivate: boolean }, + ): Promise<{ state: "pushed" | "remote_added"; htmlUrl: string }> => { + if (!readStoredToken()) { + const err = new Error("GitHub is not connected. Add a token in Settings.") as Error & { code?: string }; + err.code = "github_not_connected"; + throw err; + } + + const existingRemote = await runGit(["remote", "get-url", "origin"], { cwd: projectRoot, timeoutMs: 8_000 }); + if (existingRemote.exitCode === 0 && existingRemote.stdout.trim().length > 0) { + const err = new Error("This project already has a GitHub remote named 'origin'.") as Error & { code?: string }; + err.code = "remote_already_exists"; + throw err; + } + + const created = await createRepository({ + name: args.name, + description: args.description, + isPrivate: args.isPrivate, + }); + + const remoteAddRes = await runGit(["remote", "add", "origin", created.cloneUrl], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + if (remoteAddRes.exitCode !== 0) { + throw new Error(`Failed to add origin remote: ${remoteAddRes.stderr.trim() || `exit ${remoteAddRes.exitCode}`}`); + } + + const headRes = await runGit(["rev-parse", "--verify", "HEAD"], { cwd: projectRoot, timeoutMs: 5_000 }); + let resultState: "pushed" | "remote_added"; + if (headRes.exitCode === 0) { + const pushRes = await runGit(["push", "-u", "origin", "HEAD"], { cwd: projectRoot, timeoutMs: 5 * 60_000 }); + if (pushRes.exitCode !== 0) { + throw new Error(`Failed to push to origin: ${pushRes.stderr.trim() || `exit ${pushRes.exitCode}`}`); + } + resultState = "pushed"; + } else { + resultState = "remote_added"; + } + + cachedStatus = null; + cachedAt = 0; + + return { state: resultState, htmlUrl: created.htmlUrl }; + }; + return { getStatus, @@ -675,6 +749,12 @@ export function createGithubService({ detectRepo, apiRequest, + parseNextLink, + parseGitHubRepoFromRemoteUrl, + + // Repo creation + publish + createRepository, + publishCurrentProject, // Polling/picker read helpers listRepoLabels, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7a4101b26..b96febbfd 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -283,6 +283,14 @@ import type { ProjectDetail, ProjectIcon, ProjectInfo, + CreateProjectInput, + CreateProjectResult, + CloneProjectInput, + CloneProjectResult, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + PublishProjectInput, + PublishProjectResult, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -647,6 +655,7 @@ import type { createSyncService } from "../sync/syncService"; import type { createFeedbackReporterService } from "../feedback/feedbackReporterService"; import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; +import type { createProjectScaffoldService } from "../projects/projectScaffoldService"; import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; import { quoteWindowsCmdArg } from "../shared/processExecution"; @@ -690,6 +699,7 @@ export type AppContext = { appControlService?: ReturnType | null; builtInBrowserService?: ReturnType | null; githubService: ReturnType; + projectScaffoldService: ReturnType; prService: ReturnType; prPollingService: ReturnType; queueLandingService: ReturnType; @@ -3304,6 +3314,42 @@ export function registerIpc({ return (state.recentProjects ?? []).map(toRecentProjectSummary); }); + ipcMain.handle( + IPC.projectCreateLocal, + async (_event, arg: CreateProjectInput): Promise => { + const name = typeof arg?.name === "string" ? arg.name.trim() : ""; + const parentDir = typeof arg?.parentDir === "string" ? arg.parentDir.trim() : ""; + if (!name) throw new Error("Project name is required."); + if (!parentDir) throw new Error("Parent directory is required."); + const ctx = getCtx(); + return await ctx.projectScaffoldService.createLocalProject({ name, parentDir }); + }, + ); + + ipcMain.handle( + IPC.projectClone, + async (_event, arg: CloneProjectInput): Promise => { + const url = typeof arg?.url === "string" ? arg.url.trim() : ""; + const parentDir = typeof arg?.parentDir === "string" ? arg.parentDir.trim() : ""; + const name = typeof arg?.name === "string" ? arg.name.trim() : undefined; + if (!url) throw new Error("Repository URL is required."); + if (!parentDir) throw new Error("Parent directory is required."); + const ctx = getCtx(); + return await ctx.projectScaffoldService.cloneRepository({ + url, + parentDir, + ...(name ? { name } : {}), + }); + }, + ); + + ipcMain.handle(IPC.projectGetDefaultParentDir, async (): Promise => { + const state = readGlobalState(globalStatePath); + const recents = (state.recentProjects ?? []).map(toRecentProjectSummary); + const ctx = getCtx(); + return ctx.projectScaffoldService.getDefaultParentDir(recents); + }); + ipcMain.handle(IPC.projectCloseCurrent, async (): Promise => { await closeCurrentProject(); }); @@ -7157,6 +7203,60 @@ export function registerIpc({ }); }); + // Backend services use Error.code for known failures (e.g. + // "github_not_connected", "remote_already_exists"). Electron IPC strips + // custom properties from thrown errors, so we re-throw with the code + // prepended to the message. Renderer matches on the prefix. + const surfaceCodedError = (error: unknown): never => { + if (error instanceof Error) { + const code = (error as Error & { code?: unknown }).code; + if (typeof code === "string" && code.length > 0 && !error.message.startsWith(`${code}:`)) { + const wrapped = new Error(`${code}: ${error.message}`); + throw wrapped; + } + } + throw error; + }; + + ipcMain.handle( + IPC.githubListMyRepos, + async (_event, arg: ListMyGitHubReposInput = {}): Promise => { + const search = typeof arg?.search === "string" ? arg.search.trim() : undefined; + const ctx = getCtx(); + try { + return await ctx.projectScaffoldService.listMyGitHubRepos( + search ? { search } : {}, + ); + } catch (error) { + return surfaceCodedError(error); + } + }, + ); + + ipcMain.handle( + IPC.githubPublishCurrentProject, + async (_event, arg: PublishProjectInput): Promise => { + const name = typeof arg?.name === "string" ? arg.name.trim() : ""; + const description = typeof arg?.description === "string" ? arg.description.trim() : undefined; + const isPrivate = arg?.isPrivate !== false; + if (!name) throw new Error("Repository name is required."); + const ctx = getCtx(); + const projectRoot = ctx.project?.rootPath ?? ""; + if (!projectRoot) { + throw new Error("No active project to publish."); + } + try { + return await ctx.githubService.publishCurrentProject({ + name, + ...(description ? { description } : {}), + isPrivate, + }); + } catch (error) { + return surfaceCodedError(error); + } + }, + ); + // ── Feedback Reporter ────────────────────────────────────────────── ipcMain.handle(IPC.feedbackPrepareDraft, async (_event, arg: FeedbackPrepareDraftArgs): Promise => { const ctx = getCtx(); diff --git a/apps/desktop/src/main/services/projects/adeProjectService.test.ts b/apps/desktop/src/main/services/projects/adeProjectService.test.ts deleted file mode 100644 index 01fa8928e..000000000 --- a/apps/desktop/src/main/services/projects/adeProjectService.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildAdeGitignore, resolveAdeLayout } from "../../../shared/adeLayout"; -import { initializeOrRepairAdeProject } from "./adeProjectService"; - -function createRepoFixture(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-layout-")); - fs.mkdirSync(path.join(root, ".git", "info"), { recursive: true }); - fs.writeFileSync(path.join(root, ".gitignore"), "node_modules/\n/.ade\n", "utf8"); - fs.writeFileSync(path.join(root, ".git", "info", "exclude"), "*.tmp\n.ade/\n", "utf8"); - - fs.mkdirSync(path.join(root, ".ade", "logs"), { recursive: true }); - fs.writeFileSync(path.join(root, ".ade", "logs", "main.jsonl"), "{\"ok\":true}\n", "utf8"); - fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); - fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); - fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); - fs.mkdirSync(path.join(root, ".ade", "cto"), { recursive: true }); - fs.writeFileSync(path.join(root, ".ade", "cto", "openclaw-history.json"), "[]\n", "utf8"); - - return root; -} - -describe("initializeOrRepairAdeProject", () => { - it("creates the canonical layout, scrubs stale git excludes, and rehomes legacy state", () => { - const root = createRepoFixture(); - const layout = resolveAdeLayout(root); - - const result = initializeOrRepairAdeProject(root); - - expect(result.cleanup.changed).toBe(true); - expect(fs.readFileSync(path.join(root, ".gitignore"), "utf8")).not.toContain("/.ade"); - expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).not.toContain(".ade/"); - const adeGitignore = fs.readFileSync(path.join(layout.adeDir, ".gitignore"), "utf8"); - expect(adeGitignore).toBe(buildAdeGitignore()); - expect(adeGitignore).toContain("cto/core-memory.json"); - expect(adeGitignore).toContain("context/"); - expect(adeGitignore).toContain("agents/"); - expect(adeGitignore).toContain("cto/openclaw-history.json"); - expect(adeGitignore).not.toContain("cto/identity.yaml"); - expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("version: 1"); - expect(fs.readFileSync(path.join(layout.ctoDir, "identity.yaml"), "utf8")).toContain("name: CTO"); - expect(fs.existsSync(path.join(layout.templatesDir, ".gitkeep"))).toBe(true); - expect(fs.existsSync(path.join(layout.skillsDir, ".gitkeep"))).toBe(true); - expect(fs.existsSync(layout.linearWorkflowsDir)).toBe(true); - expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); - expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.cacheDir, "openclaw", "openclaw-history.json"))).toBe(true); - expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); - expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); - expect(fs.existsSync(path.join(layout.ctoDir, "openclaw-history.json"))).toBe(false); - }); - - it("is idempotent once the canonical structure is in place", () => { - const root = createRepoFixture(); - - initializeOrRepairAdeProject(root); - const second = initializeOrRepairAdeProject(root); - - expect(second.cleanup.changed).toBe(false); - expect(second.cleanup.actions).toHaveLength(0); - }); - - it("does not overwrite an existing shared ade.yaml", () => { - const root = createRepoFixture(); - const layout = resolveAdeLayout(root); - fs.mkdirSync(layout.adeDir, { recursive: true }); - fs.writeFileSync(path.join(layout.adeDir, "ade.yaml"), "version: 1\nprocesses:\n - id: keep-me\n", "utf8"); - - initializeOrRepairAdeProject(root); - - expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("keep-me"); - }); -}); diff --git a/apps/desktop/src/main/services/projects/projectBrowserService.test.ts b/apps/desktop/src/main/services/projects/projectBrowserService.test.ts deleted file mode 100644 index f1616a8df..000000000 --- a/apps/desktop/src/main/services/projects/projectBrowserService.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { browseProjectDirectories } from "./projectBrowserService"; - -vi.mock("./projectService", () => ({ - resolveRepoRoot: vi.fn(async (selectedPath: string) => { - const normalized = path.resolve(selectedPath); - if (normalized.endsWith(path.join("alpha", "nested"))) { - return path.dirname(normalized); - } - if (normalized.endsWith("alpha")) { - return normalized; - } - throw new Error("Not a git repository"); - }), -})); - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - vi.clearAllMocks(); - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); - -describe("browseProjectDirectories", () => { - it("returns matching directories for a partial path", async () => { - const root = makeTempDir("ade-project-browse-"); - fs.mkdirSync(path.join(root, "alpha")); - fs.mkdirSync(path.join(root, "alpine")); - fs.writeFileSync(path.join(root, "alpha.txt"), "ignore me", "utf8"); - - const result = await browseProjectDirectories({ - partialPath: path.join(root, "alp"), - }); - - expect(result.exactDirectoryPath).toBeNull(); - expect(result.directoryPath).toBe(root); - expect(result.openableProjectRoot).toBeNull(); - expect(result.entries.map((entry) => entry.name)).toEqual(["alpha", "alpine"]); - }); - - it("lists directory contents and reports an openable repo for an exact directory", async () => { - const root = makeTempDir("ade-project-browse-dir-"); - const repoRoot = path.join(root, "alpha"); - fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); - fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); - fs.mkdirSync(path.join(repoRoot, ".config"), { recursive: true }); - fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); - - const result = await browseProjectDirectories({ - partialPath: repoRoot, - }); - - expect(result.exactDirectoryPath).toBe(repoRoot); - expect(result.openableProjectRoot).toBe(repoRoot); - expect(result.entries.map((entry) => entry.name)).toEqual([".config", ".git", "nested", "src"]); - }); - - it("supports relative paths when cwd is provided", async () => { - const root = makeTempDir("ade-project-browse-rel-"); - const repoRoot = path.join(root, "alpha"); - fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); - fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); - - const result = await browseProjectDirectories({ - cwd: repoRoot, - partialPath: "./nested", - }); - - expect(result.exactDirectoryPath).toBe(path.join(repoRoot, "nested")); - expect(result.openableProjectRoot).toBe(repoRoot); - }); - - it("marks entries with a .git directory as git repos", async () => { - const root = makeTempDir("ade-project-browse-flags-"); - fs.mkdirSync(path.join(root, "alpha", ".git"), { recursive: true }); - fs.mkdirSync(path.join(root, "alpine")); - - const result = await browseProjectDirectories({ - partialPath: withTrailingSlash(root), - }); - - const byName = new Map(result.entries.map((entry) => [entry.name, entry])); - expect(byName.get("alpha")?.isGitRepo).toBe(true); - expect(byName.get("alpine")?.isGitRepo).toBe(false); - }); -}); - -function withTrailingSlash(input: string): string { - return input.endsWith(path.sep) ? input : `${input}${path.sep}`; -} diff --git a/apps/desktop/src/main/services/projects/projectDetailService.test.ts b/apps/desktop/src/main/services/projects/projectDetailService.test.ts deleted file mode 100644 index 940c1e171..000000000 --- a/apps/desktop/src/main/services/projects/projectDetailService.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { __internal, getProjectDetail } from "./projectDetailService"; - -const { parseLastCommitLine, parseAheadBehind } = __internal; -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); - -describe("parseLastCommitLine", () => { - it("splits subject, iso date, and short sha on the unit separator", () => { - const parsed = parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z\u001fabcdef1\n"); - expect(parsed).toEqual({ - subject: "Fix thing", - isoDate: "2026-04-15T09:00:00Z", - shortSha: "abcdef1", - }); - }); - - it("returns null when any segment is missing", () => { - expect(parseLastCommitLine("")).toBeNull(); - expect(parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z")).toBeNull(); - expect(parseLastCommitLine("Fix thing\u001f\u001fabcdef1")).toBeNull(); - }); -}); - -describe("parseAheadBehind", () => { - it("reads the left-right count output (behind, ahead)", () => { - expect(parseAheadBehind("3\t2\n")).toEqual({ ahead: 2, behind: 3 }); - expect(parseAheadBehind("0\t0")).toEqual({ ahead: 0, behind: 0 }); - }); - - it("returns null when the output cannot be parsed", () => { - expect(parseAheadBehind("")).toBeNull(); - expect(parseAheadBehind("abc")).toBeNull(); - }); -}); - -describe("getProjectDetail", () => { - it("rejects paths that are not existing directories", async () => { - const root = makeTempDir("ade-project-detail-"); - const filePath = path.join(root, "README.md"); - fs.writeFileSync(filePath, "# hello\n", "utf8"); - - await expect(getProjectDetail(filePath)).rejects.toThrow(/existing directory/i); - await expect(getProjectDetail(path.join(root, "missing-project"))).rejects.toThrow(/existing directory/i); - }); - - it("strips repeated leading HTML comments from README excerpts", async () => { - const root = makeTempDir("ade-project-detail-"); - fs.mkdirSync(path.join(root, ".git")); - fs.writeFileSync( - path.join(root, "README.md"), - "\n\n# Hello\n\nVisible body.\n", - "utf8", - ); - - const detail = await getProjectDetail(root); - - expect(detail.readmeExcerpt).toBe("# Hello\n\nVisible body."); - }); - - it("keeps plain folder details lightweight", async () => { - const root = makeTempDir("ade-project-detail-"); - fs.mkdirSync(path.join(root, "nested")); - fs.writeFileSync(path.join(root, "README.md"), "# Plain folder\n", "utf8"); - fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n", "utf8"); - - const detail = await getProjectDetail(root); - - expect(detail.isGitRepo).toBe(false); - expect(detail.readmeExcerpt).toBeNull(); - expect(detail.languages).toEqual([]); - expect(detail.subdirectoryCount).toBe(1); - }); -}); diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts new file mode 100644 index 000000000..6d33c75c4 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -0,0 +1,448 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildAdeGitignore, resolveAdeLayout } from "../../../shared/adeLayout"; +import { initializeOrRepairAdeProject } from "./adeProjectService"; +import { browseProjectDirectories } from "./projectBrowserService"; +import { __internal, getProjectDetail } from "./projectDetailService"; +import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; +import { openKvDb } from "../state/kvDb"; + +const { parseLastCommitLine, parseAheadBehind } = __internal; + +vi.mock("./projectService", () => ({ + resolveRepoRoot: vi.fn(async (selectedPath: string) => { + const normalized = path.resolve(selectedPath); + if (normalized.endsWith(path.join("alpha", "nested"))) { + return path.dirname(normalized); + } + if (normalized.endsWith("alpha")) { + return normalized; + } + throw new Error("Not a git repository"); + }), +})); + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function withTrailingSlash(input: string): string { + return input.endsWith(path.sep) ? input : `${input}${path.sep}`; +} + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +function insertProject( + db: Awaited>, + projectId: string, + projectRoot: string, + now: string, +) { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, projectRoot, path.basename(projectRoot), "main", now, now], + ); +} + +function insertLane( + db: Awaited>, + args: { + laneId: string; + projectId: string; + laneType: "primary" | "worktree" | "attached"; + worktreePath: string; + branchRef: string; + status?: "active" | "archived"; + archivedAt?: string | null; + attachedRootPath?: string | null; + }, +) { + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + args.laneId, + args.projectId, + args.laneId, + null, + args.laneType, + "main", + args.branchRef, + args.worktreePath, + args.attachedRootPath ?? null, + args.laneType === "primary" ? 1 : 0, + null, + null, + null, + null, + args.status ?? "active", + "2026-04-02T12:00:00.000Z", + args.archivedAt ?? null, + ], + ); +} + +afterEach(() => { + vi.clearAllMocks(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// --------------------------------------------------------------------------- +// initializeOrRepairAdeProject — canonical .ade/ layout init + repair +// --------------------------------------------------------------------------- + +describe("initializeOrRepairAdeProject", () => { + function createRepoFixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-layout-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, ".git", "info"), { recursive: true }); + fs.writeFileSync(path.join(root, ".gitignore"), "node_modules/\n/.ade\n", "utf8"); + fs.writeFileSync(path.join(root, ".git", "info", "exclude"), "*.tmp\n.ade/\n", "utf8"); + + fs.mkdirSync(path.join(root, ".ade", "logs"), { recursive: true }); + fs.writeFileSync(path.join(root, ".ade", "logs", "main.jsonl"), "{\"ok\":true}\n", "utf8"); + fs.mkdirSync(path.join(root, ".ade", "chat-sessions"), { recursive: true }); + fs.writeFileSync(path.join(root, ".ade", "chat-sessions", "session-1.json"), "{\"id\":\"session-1\"}\n", "utf8"); + fs.writeFileSync(path.join(root, ".ade", "mission-state-run-1.json"), "{\"runId\":\"run-1\"}\n", "utf8"); + fs.mkdirSync(path.join(root, ".ade", "cto"), { recursive: true }); + fs.writeFileSync(path.join(root, ".ade", "cto", "openclaw-history.json"), "[]\n", "utf8"); + + return root; + } + + it("creates the canonical layout, scrubs stale git excludes, and rehomes legacy state", () => { + const root = createRepoFixture(); + const layout = resolveAdeLayout(root); + + const result = initializeOrRepairAdeProject(root); + + expect(result.cleanup.changed).toBe(true); + expect(fs.readFileSync(path.join(root, ".gitignore"), "utf8")).not.toContain("/.ade"); + expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).not.toContain(".ade/"); + const adeGitignore = fs.readFileSync(path.join(layout.adeDir, ".gitignore"), "utf8"); + expect(adeGitignore).toBe(buildAdeGitignore()); + expect(adeGitignore).toContain("cto/core-memory.json"); + expect(adeGitignore).toContain("context/"); + expect(adeGitignore).toContain("agents/"); + expect(adeGitignore).toContain("cto/openclaw-history.json"); + expect(adeGitignore).not.toContain("cto/identity.yaml"); + expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("version: 1"); + expect(fs.readFileSync(path.join(layout.ctoDir, "identity.yaml"), "utf8")).toContain("name: CTO"); + expect(fs.existsSync(path.join(layout.templatesDir, ".gitkeep"))).toBe(true); + expect(fs.existsSync(path.join(layout.skillsDir, ".gitkeep"))).toBe(true); + expect(fs.existsSync(layout.linearWorkflowsDir)).toBe(true); + expect(fs.existsSync(path.join(layout.logsDir, "main.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(layout.chatSessionsDir, "session-1.json"))).toBe(true); + expect(fs.existsSync(path.join(layout.missionStateDir, "mission-state-run-1.json"))).toBe(true); + expect(fs.existsSync(path.join(layout.cacheDir, "openclaw", "openclaw-history.json"))).toBe(true); + expect(fs.existsSync(path.join(layout.adeDir, "logs"))).toBe(false); + expect(fs.existsSync(path.join(layout.adeDir, "chat-sessions"))).toBe(false); + expect(fs.existsSync(path.join(layout.ctoDir, "openclaw-history.json"))).toBe(false); + }); + + it("is idempotent once the canonical structure is in place", () => { + const root = createRepoFixture(); + + initializeOrRepairAdeProject(root); + const second = initializeOrRepairAdeProject(root); + + expect(second.cleanup.changed).toBe(false); + expect(second.cleanup.actions).toHaveLength(0); + }); + + it("does not overwrite an existing shared ade.yaml", () => { + const root = createRepoFixture(); + const layout = resolveAdeLayout(root); + fs.mkdirSync(layout.adeDir, { recursive: true }); + fs.writeFileSync(path.join(layout.adeDir, "ade.yaml"), "version: 1\nprocesses:\n - id: keep-me\n", "utf8"); + + initializeOrRepairAdeProject(root); + + expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("keep-me"); + }); +}); + +// --------------------------------------------------------------------------- +// browseProjectDirectories — directory picker for "Add Project" +// --------------------------------------------------------------------------- + +describe("browseProjectDirectories", () => { + it("returns matching directories for a partial path", async () => { + const root = makeTempDir("ade-project-browse-"); + fs.mkdirSync(path.join(root, "alpha")); + fs.mkdirSync(path.join(root, "alpine")); + fs.writeFileSync(path.join(root, "alpha.txt"), "ignore me", "utf8"); + + const result = await browseProjectDirectories({ + partialPath: path.join(root, "alp"), + }); + + expect(result.exactDirectoryPath).toBeNull(); + expect(result.directoryPath).toBe(root); + expect(result.openableProjectRoot).toBeNull(); + expect(result.entries.map((entry) => entry.name)).toEqual(["alpha", "alpine"]); + }); + + it("lists directory contents and reports an openable repo for an exact directory", async () => { + const root = makeTempDir("ade-project-browse-dir-"); + const repoRoot = path.join(root, "alpha"); + fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".config"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + + const result = await browseProjectDirectories({ + partialPath: repoRoot, + }); + + expect(result.exactDirectoryPath).toBe(repoRoot); + expect(result.openableProjectRoot).toBe(repoRoot); + expect(result.entries.map((entry) => entry.name)).toEqual([".config", ".git", "nested", "src"]); + }); + + it("supports relative paths when cwd is provided", async () => { + const root = makeTempDir("ade-project-browse-rel-"); + const repoRoot = path.join(root, "alpha"); + fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + + const result = await browseProjectDirectories({ + cwd: repoRoot, + partialPath: "./nested", + }); + + expect(result.exactDirectoryPath).toBe(path.join(repoRoot, "nested")); + expect(result.openableProjectRoot).toBe(repoRoot); + }); + + it("marks entries with a .git directory as git repos", async () => { + const root = makeTempDir("ade-project-browse-flags-"); + fs.mkdirSync(path.join(root, "alpha", ".git"), { recursive: true }); + fs.mkdirSync(path.join(root, "alpine")); + + const result = await browseProjectDirectories({ + partialPath: withTrailingSlash(root), + }); + + const byName = new Map(result.entries.map((entry) => [entry.name, entry])); + expect(byName.get("alpha")?.isGitRepo).toBe(true); + expect(byName.get("alpine")?.isGitRepo).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getProjectDetail + parsers — project preview details +// --------------------------------------------------------------------------- + +describe("parseLastCommitLine", () => { + it("splits subject, iso date, and short sha on the unit separator", () => { + const parsed = parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z\u001fabcdef1\n"); + expect(parsed).toEqual({ + subject: "Fix thing", + isoDate: "2026-04-15T09:00:00Z", + shortSha: "abcdef1", + }); + }); + + it("returns null when any segment is missing", () => { + expect(parseLastCommitLine("")).toBeNull(); + expect(parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z")).toBeNull(); + expect(parseLastCommitLine("Fix thing\u001f\u001fabcdef1")).toBeNull(); + }); +}); + +describe("parseAheadBehind", () => { + it("reads the left-right count output (behind, ahead)", () => { + expect(parseAheadBehind("3\t2\n")).toEqual({ ahead: 2, behind: 3 }); + expect(parseAheadBehind("0\t0")).toEqual({ ahead: 0, behind: 0 }); + }); + + it("returns null when the output cannot be parsed", () => { + expect(parseAheadBehind("")).toBeNull(); + expect(parseAheadBehind("abc")).toBeNull(); + }); +}); + +describe("getProjectDetail", () => { + it("rejects paths that are not existing directories", async () => { + const root = makeTempDir("ade-project-detail-"); + const filePath = path.join(root, "README.md"); + fs.writeFileSync(filePath, "# hello\n", "utf8"); + + await expect(getProjectDetail(filePath)).rejects.toThrow(/existing directory/i); + await expect(getProjectDetail(path.join(root, "missing-project"))).rejects.toThrow(/existing directory/i); + }); + + it("strips repeated leading HTML comments from README excerpts", async () => { + const root = makeTempDir("ade-project-detail-"); + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync( + path.join(root, "README.md"), + "\n\n# Hello\n\nVisible body.\n", + "utf8", + ); + + const detail = await getProjectDetail(root); + + expect(detail.readmeExcerpt).toBe("# Hello\n\nVisible body."); + }); + + it("keeps plain folder details lightweight", async () => { + const root = makeTempDir("ade-project-detail-"); + fs.mkdirSync(path.join(root, "nested")); + fs.writeFileSync(path.join(root, "README.md"), "# Plain folder\n", "utf8"); + fs.writeFileSync(path.join(root, "index.ts"), "export const value = 1;\n", "utf8"); + + const detail = await getProjectDetail(root); + + expect(detail.isGitRepo).toBe(false); + expect(detail.readmeExcerpt).toBeNull(); + expect(detail.languages).toEqual([]); + expect(detail.subdirectoryCount).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// toRecentProjectSummary — lane counting for recent-project tiles +// --------------------------------------------------------------------------- + +describe("toRecentProjectSummary", () => { + it("prefers active ADE lanes over raw git worktree metadata", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-summary-")); + tempDirs.push(projectRoot); + const gitWorktreesDir = path.join(projectRoot, ".git", "worktrees"); + fs.mkdirSync(gitWorktreesDir, { recursive: true }); + for (let index = 0; index < 18; index += 1) { + fs.mkdirSync(path.join(gitWorktreesDir, `raw-${index}`), { recursive: true }); + } + + const layout = resolveAdeLayout(projectRoot); + const managedLanePath = path.join(layout.worktreesDir, "lane-managed"); + const missingLanePath = path.join(layout.worktreesDir, "lane-missing"); + const attachedLanePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-attached-")); + tempDirs.push(attachedLanePath); + fs.mkdirSync(managedLanePath, { recursive: true }); + + const db = await openKvDb(layout.dbPath, createLogger()); + const now = "2026-04-02T12:00:00.000Z"; + insertProject(db, "proj-recent", projectRoot, now); + insertLane(db, { + laneId: "lane-primary", + projectId: "proj-recent", + laneType: "primary", + worktreePath: projectRoot, + branchRef: "main", + }); + insertLane(db, { + laneId: "lane-managed", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: managedLanePath, + branchRef: "feature/managed", + }); + insertLane(db, { + laneId: "lane-missing", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: missingLanePath, + branchRef: "feature/missing", + }); + insertLane(db, { + laneId: "lane-attached", + projectId: "proj-recent", + laneType: "attached", + worktreePath: attachedLanePath, + attachedRootPath: attachedLanePath, + branchRef: "feature/attached", + }); + insertLane(db, { + laneId: "lane-archived", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: path.join(layout.worktreesDir, "lane-archived"), + branchRef: "feature/archived", + status: "archived", + archivedAt: now, + }); + db.close(); + + const inspection = inspectRecentProject({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: now, + }); + const summary = inspection.summary; + + expect(summary.exists).toBe(true); + expect(summary.laneCount).toBe(3); + expect(inspection.projectId).toBe("proj-recent"); + expect(inspection.defaultBaseRef).toBe("main"); + }); + + it("falls back to git worktree metadata when no ADE lane registry exists", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-git-fallback-")); + tempDirs.push(projectRoot); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-b"), { recursive: true }); + + const summary = toRecentProjectSummary({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: "2026-04-02T12:00:00.000Z", + }); + + expect(summary.laneCount).toBe(3); + }); + + it("falls back to git worktree metadata when ADE only has archived lanes", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-archived-fallback-")); + tempDirs.push(projectRoot); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); + + const layout = resolveAdeLayout(projectRoot); + const db = await openKvDb(layout.dbPath, createLogger()); + const now = "2026-04-02T12:00:00.000Z"; + insertProject(db, "proj-recent-archived", projectRoot, now); + insertLane(db, { + laneId: "lane-primary-archived", + projectId: "proj-recent-archived", + laneType: "primary", + worktreePath: projectRoot, + branchRef: "main", + status: "archived", + archivedAt: now, + }); + db.close(); + + const summary = toRecentProjectSummary({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: now, + }); + + expect(summary.laneCount).toBe(2); + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts new file mode 100644 index 000000000..e9b1bd676 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -0,0 +1,489 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runGitMock = vi.hoisted(() => vi.fn()); + +vi.mock("../git/git", () => ({ + runGit: runGitMock, +})); + +import { createProjectScaffoldService } from "./projectScaffoldService"; + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeGithubServiceStub(overrides: Partial<{ + apiRequest: ReturnType; + getTokenOrThrow: ReturnType; + parseGitHubRepoFromRemoteUrl: ReturnType; +}> = {}) { + return { + apiRequest: overrides.apiRequest ?? vi.fn(), + getTokenOrThrow: overrides.getTokenOrThrow ?? vi.fn(() => "ghp_fake_token_12345"), + parseGitHubRepoFromRemoteUrl: + overrides.parseGitHubRepoFromRemoteUrl ?? + vi.fn((url: string) => { + if (url.startsWith("https://github.com/") || url.startsWith("git@github.com:")) { + const slug = url.replace(/^https:\/\/github\.com\//, "").replace(/^git@github\.com:/, "").replace(/\.git$/, ""); + const [owner, name] = slug.split("/"); + if (owner && name) return { owner, name }; + } + return null; + }), + parseNextLink: vi.fn(), + } as any; +} + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function gitOk(stdout = ""): { exitCode: 0; stdout: string; stderr: string } { + return { exitCode: 0, stdout, stderr: "" }; +} + +function gitFail(stderr: string, exitCode = 1): { exitCode: number; stdout: string; stderr: string } { + return { exitCode, stdout: "", stderr }; +} + +afterEach(() => { + vi.clearAllMocks(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("createLocalProject", () => { + beforeEach(() => { + runGitMock.mockReset(); + }); + + it("creates README + .gitignore and runs init/add/commit on main", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-create-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + const result = await service.createLocalProject({ name: "my-project", parentDir }); + + expect(result.rootPath).toBe(path.join(parentDir, "my-project")); + expect(fs.readFileSync(path.join(result.rootPath, "README.md"), "utf8")).toBe("# my-project\n"); + const gitignore = fs.readFileSync(path.join(result.rootPath, ".gitignore"), "utf8"); + expect(gitignore).toContain("node_modules/"); + expect(gitignore).toContain(".ade/"); + expect(gitignore).toContain(".env"); + expect(gitignore).toContain("*.log"); + + const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]); + expect(argsList[0]).toEqual(["init", "--initial-branch=main"]); + expect(argsList).toContainEqual(["add", "."]); + expect(argsList).toContainEqual(["commit", "-m", "Initial commit"]); + }); + + it("falls back to plain init + symbolic-ref when --initial-branch is unsupported", async () => { + runGitMock + .mockResolvedValueOnce(gitFail("error: unknown option `initial-branch'")) + .mockResolvedValueOnce(gitOk()) // git init (plain) + .mockResolvedValueOnce(gitOk()) // symbolic-ref + .mockResolvedValueOnce(gitOk()) // add . + .mockResolvedValueOnce(gitOk()); // commit + + const parentDir = makeTempDir("ade-scaffold-fallback-init-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await service.createLocalProject({ name: "legacy-git", parentDir }); + + const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]); + expect(argsList[0]).toEqual(["init", "--initial-branch=main"]); + expect(argsList[1]).toEqual(["init"]); + expect(argsList[2]).toEqual(["symbolic-ref", "HEAD", "refs/heads/main"]); + }); + + it("retries the initial commit with the ADE author when git identity is missing", async () => { + runGitMock + .mockResolvedValueOnce(gitOk()) // init --initial-branch=main + .mockResolvedValueOnce(gitOk()) // add . + .mockResolvedValueOnce(gitFail("Please tell me who you are.")) + .mockResolvedValueOnce(gitOk()); // retry commit + + const parentDir = makeTempDir("ade-scaffold-author-fallback-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await service.createLocalProject({ name: "no-config-project", parentDir }); + + const calls = runGitMock.mock.calls; + expect(calls).toHaveLength(4); + expect(calls[2]?.[0]).toEqual(["commit", "-m", "Initial commit"]); + expect(calls[3]?.[0]).toEqual([ + "commit", + "-m", + "Initial commit", + "--author=ADE ", + ]); + const retryEnv = (calls[3]?.[1] as { env?: Record }).env ?? {}; + expect(retryEnv.GIT_COMMITTER_NAME).toBe("ADE"); + expect(retryEnv.GIT_COMMITTER_EMAIL).toBe("ade@local"); + }); + + it("does not throw when the author-fallback commit also fails (best-effort)", async () => { + runGitMock + .mockResolvedValueOnce(gitOk()) + .mockResolvedValueOnce(gitOk()) + .mockResolvedValueOnce(gitFail("Please tell me who you are.")) + .mockResolvedValueOnce(gitFail("still no identity")); + + const parentDir = makeTempDir("ade-scaffold-author-retry-fail-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.createLocalProject({ name: "uncommittable", parentDir }), + ).resolves.toEqual({ rootPath: path.join(parentDir, "uncommittable") }); + }); + + it("rejects names with path separators", async () => { + const parentDir = makeTempDir("ade-scaffold-name-sep-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.createLocalProject({ name: "bad/name", parentDir }), + ).rejects.toThrow(/path separators/i); + await expect( + service.createLocalProject({ name: "bad\\name", parentDir }), + ).rejects.toThrow(/path separators/i); + }); + + it("rejects empty/dotted/oversized names", async () => { + const parentDir = makeTempDir("ade-scaffold-name-edge-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect(service.createLocalProject({ name: " ", parentDir })).rejects.toThrow(/required/i); + await expect(service.createLocalProject({ name: ".hidden", parentDir })).rejects.toThrow(/dot/i); + await expect( + service.createLocalProject({ name: "a".repeat(101), parentDir }), + ).rejects.toThrow(/100/); + }); + + it("rejects when target directory already exists and is non-empty", async () => { + const parentDir = makeTempDir("ade-scaffold-target-exists-"); + const collision = path.join(parentDir, "collision"); + fs.mkdirSync(collision); + fs.writeFileSync(path.join(collision, "preexisting.txt"), "hi"); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.createLocalProject({ name: "collision", parentDir }), + ).rejects.toThrow("target_exists"); + }); + + it("allows reusing an empty existing directory as the target", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-target-empty-"); + const empty = path.join(parentDir, "empty-target"); + fs.mkdirSync(empty); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.createLocalProject({ name: "empty-target", parentDir }), + ).resolves.toEqual({ rootPath: empty }); + }); +}); + +describe("cloneRepository", () => { + beforeEach(() => { + runGitMock.mockReset(); + }); + + it("rejects URLs that are not GitHub", async () => { + const parentDir = makeTempDir("ade-scaffold-clone-bad-url-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ + parseGitHubRepoFromRemoteUrl: vi.fn(() => null), + }), + }); + + await expect( + service.cloneRepository({ url: "https://gitlab.com/foo/bar.git", parentDir }), + ).rejects.toThrow("invalid_github_url"); + }); + + it("rejects empty URLs", async () => { + const parentDir = makeTempDir("ade-scaffold-clone-empty-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.cloneRepository({ url: "", parentDir }), + ).rejects.toThrow("invalid_github_url"); + }); + + it("invokes git clone with the URL and target path, deriving name from the URL", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-clone-default-name-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + const result = await service.cloneRepository({ + url: "https://github.com/octocat/Hello-World", + parentDir, + }); + + expect(result.rootPath).toBe(path.join(parentDir, "Hello-World")); + const cloneCall = runGitMock.mock.calls.find((c) => (c[0] as string[])[0] === "clone"); + expect(cloneCall?.[0]).toEqual(["clone", "https://github.com/octocat/Hello-World", path.join(parentDir, "Hello-World")]); + }); + + it("uses the explicit name override when provided", async () => { + runGitMock.mockResolvedValue(gitOk()); + const parentDir = makeTempDir("ade-scaffold-clone-override-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + const result = await service.cloneRepository({ + url: "https://github.com/octocat/Hello-World", + parentDir, + name: "renamed-checkout", + }); + + expect(result.rootPath).toBe(path.join(parentDir, "renamed-checkout")); + }); + + it("rejects when the target directory already exists and is non-empty", async () => { + const parentDir = makeTempDir("ade-scaffold-clone-collision-"); + const dest = path.join(parentDir, "Hello-World"); + fs.mkdirSync(dest); + fs.writeFileSync(path.join(dest, "x"), "x"); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.cloneRepository({ url: "https://github.com/octocat/Hello-World", parentDir }), + ).rejects.toThrow("target_exists"); + }); + + it("surfaces git clone failures with stderr", async () => { + runGitMock.mockResolvedValue(gitFail("Repository not found")); + const parentDir = makeTempDir("ade-scaffold-clone-fail-"); + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + await expect( + service.cloneRepository({ url: "https://github.com/octocat/Hello-World", parentDir }), + ).rejects.toThrow(/repository not found/i); + }); +}); + +describe("listMyGitHubRepos", () => { + it("throws github_not_connected when no token is available", async () => { + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ + getTokenOrThrow: vi.fn(() => { + throw new Error("GitHub token missing. Set it in Settings."); + }), + }), + }); + + let caught: any; + try { + await service.listMyGitHubRepos({}); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect(caught.code).toBe("github_not_connected"); + }); + + it("paginates the GitHub /user/repos endpoint and stops on partial pages", async () => { + const apiRequest = vi.fn(); + + const makeRepo = (id: number) => ({ + owner: { login: "alice" }, + name: `repo-${id}`, + full_name: `alice/repo-${id}`, + private: id % 2 === 0, + pushed_at: "2026-04-01T12:00:00Z", + default_branch: "main", + html_url: `https://github.com/alice/repo-${id}`, + clone_url: `https://github.com/alice/repo-${id}.git`, + ssh_url: `git@github.com:alice/repo-${id}.git`, + }); + + const fullPage = Array.from({ length: 100 }, (_, i) => makeRepo(i)); + const partialPage = [makeRepo(100), makeRepo(101)]; + + apiRequest + .mockResolvedValueOnce({ data: fullPage, response: null }) + .mockResolvedValueOnce({ data: partialPage, response: null }); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ apiRequest }), + }); + + const result = await service.listMyGitHubRepos({}); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(apiRequest.mock.calls[0]?.[0]).toMatchObject({ + method: "GET", + path: "/user/repos", + query: expect.objectContaining({ + per_page: 100, + sort: "pushed", + affiliation: "owner,collaborator,organization_member", + page: 1, + }), + }); + expect(apiRequest.mock.calls[1]?.[0]).toMatchObject({ + query: expect.objectContaining({ page: 2 }), + }); + expect(result.repos).toHaveLength(102); + expect(result.repos[0]).toMatchObject({ + owner: "alice", + name: "repo-0", + fullName: "alice/repo-0", + isPrivate: true, + defaultBranch: "main", + }); + }); + + it("filters by case-insensitive substring on fullName", async () => { + const apiRequest = vi.fn().mockResolvedValueOnce({ + data: [ + { owner: { login: "alice" }, name: "ade", full_name: "alice/ade", private: false, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" }, + { owner: { login: "alice" }, name: "infra", full_name: "alice/infra", private: false, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" }, + { owner: { login: "alice" }, name: "ADE-tools", full_name: "alice/ADE-tools", private: true, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" }, + ], + response: null, + }); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ apiRequest }), + }); + + const result = await service.listMyGitHubRepos({ search: "ADE" }); + expect(result.repos.map((r) => r.fullName)).toEqual(["alice/ade", "alice/ADE-tools"]); + }); + + it("returns cached results within the 60s window without re-hitting the API", async () => { + const apiRequest = vi.fn().mockResolvedValueOnce({ + data: [ + { owner: { login: "alice" }, name: "cached", full_name: "alice/cached", private: false, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" }, + ], + response: null, + }); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ apiRequest }), + }); + + await service.listMyGitHubRepos({}); + await service.listMyGitHubRepos({}); + + expect(apiRequest).toHaveBeenCalledTimes(1); + }); + + it("stops paginating after 5 pages even if every page is full", async () => { + const apiRequest = vi.fn(); + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + owner: { login: "alice" }, + name: `repo-${i}`, + full_name: `alice/repo-${i}`, + private: false, + pushed_at: null, + default_branch: "main", + html_url: "", + clone_url: "", + ssh_url: "", + })); + apiRequest.mockResolvedValue({ data: fullPage, response: null }); + + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub({ apiRequest }), + }); + + const result = await service.listMyGitHubRepos({}); + + expect(apiRequest).toHaveBeenCalledTimes(5); + expect(result.repos).toHaveLength(500); + }); +}); + +describe("getDefaultParentDir", () => { + it("returns the parent of the most recent project's rootPath", () => { + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + const result = service.getDefaultParentDir([ + { rootPath: "/Users/alice/code/ade" }, + { rootPath: "/Users/alice/work/other" }, + ]); + + expect(result).toBe("/Users/alice/code"); + }); + + it("falls back to ~/Projects when no recent projects are available", () => { + const service = createProjectScaffoldService({ + logger: makeLogger(), + githubService: makeGithubServiceStub(), + }); + + expect(service.getDefaultParentDir([])).toBe(path.join(os.homedir(), "Projects")); + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts new file mode 100644 index 000000000..3ec3b2a33 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -0,0 +1,271 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + CloneProjectInput, + CloneProjectResult, + CreateProjectInput, + CreateProjectResult, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + MyGitHubRepoSummary, +} from "../../../shared/types/core"; +import { runGit } from "../git/git"; +import type { Logger } from "../logging/logger"; +import type { createGithubService } from "../github/githubService"; + +type GithubService = ReturnType; + +const GITIGNORE_CONTENT = [ + "node_modules/", + "dist/", + "build/", + ".DS_Store", + ".ade/", + ".env", + ".env.*", + "*.log", + "", +].join("\n"); + +const REPO_LIST_CACHE_TTL_MS = 60_000; + +function validateProjectName(name: string): string { + const trimmed = (name ?? "").trim(); + if (!trimmed.length) { + throw new Error("Project name is required."); + } + if (trimmed.length > 100) { + throw new Error("Project name must be 100 characters or fewer."); + } + if (trimmed.includes("/") || trimmed.includes("\\")) { + throw new Error("Project name cannot contain path separators."); + } + if (trimmed.startsWith(".")) { + throw new Error("Project name cannot start with a dot."); + } + if (trimmed.includes("\0")) { + throw new Error("Project name contains an invalid character."); + } + return trimmed; +} + +function isDirectoryNonEmpty(dirPath: string): boolean { + try { + const entries = fs.readdirSync(dirPath); + return entries.length > 0; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +function isGitIdentityError(stderr: string): boolean { + const text = stderr.toLowerCase(); + return ( + text.includes("please tell me who you are") || + text.includes("user.email") || + text.includes("author identity") + ); +} + +export function createProjectScaffoldService({ + logger, + githubService, +}: { + logger: Logger; + githubService: GithubService; +}) { + let cachedRepos: { tokenPrefix: string; expiresAt: number; repos: MyGitHubRepoSummary[] } | null = null; + + const createLocalProject = async (input: CreateProjectInput): Promise => { + const name = validateProjectName(input.name); + const parentDir = (input.parentDir ?? "").trim(); + if (!parentDir.length) { + throw new Error("Parent directory is required."); + } + + const rootPath = path.join(parentDir, name); + + if (fs.existsSync(rootPath) && isDirectoryNonEmpty(rootPath)) { + throw new Error("target_exists"); + } + + fs.mkdirSync(rootPath, { recursive: true }); + + const initRes = await runGit(["init", "--initial-branch=main"], { cwd: rootPath, timeoutMs: 15_000 }); + if (initRes.exitCode !== 0) { + const fallbackInit = await runGit(["init"], { cwd: rootPath, timeoutMs: 15_000 }); + if (fallbackInit.exitCode !== 0) { + throw new Error(`git init failed: ${fallbackInit.stderr.trim() || `exit ${fallbackInit.exitCode}`}`); + } + const symbolicRef = await runGit(["symbolic-ref", "HEAD", "refs/heads/main"], { + cwd: rootPath, + timeoutMs: 5_000, + }); + if (symbolicRef.exitCode !== 0) { + logger.warn("project_scaffold.symbolic_ref_failed", { + rootPath, + stderr: symbolicRef.stderr.trim(), + }); + } + } + + fs.writeFileSync(path.join(rootPath, "README.md"), `# ${name}\n`, "utf8"); + fs.writeFileSync(path.join(rootPath, ".gitignore"), GITIGNORE_CONTENT, "utf8"); + + const addRes = await runGit(["add", "."], { cwd: rootPath, timeoutMs: 15_000 }); + if (addRes.exitCode !== 0) { + throw new Error(`git add failed: ${addRes.stderr.trim() || `exit ${addRes.exitCode}`}`); + } + + const commitRes = await runGit(["commit", "-m", "Initial commit"], { cwd: rootPath, timeoutMs: 15_000 }); + if (commitRes.exitCode !== 0) { + if (isGitIdentityError(commitRes.stderr)) { + const retry = await runGit( + ["commit", "-m", "Initial commit", "--author=ADE "], + { + cwd: rootPath, + timeoutMs: 15_000, + env: { + ...process.env, + GIT_COMMITTER_NAME: "ADE", + GIT_COMMITTER_EMAIL: "ade@local", + }, + }, + ); + if (retry.exitCode !== 0) { + logger.warn("project_scaffold.initial_commit_retry_failed", { + rootPath, + stderr: retry.stderr.trim(), + }); + } + } else { + logger.warn("project_scaffold.initial_commit_failed", { + rootPath, + stderr: commitRes.stderr.trim(), + }); + } + } + + return { rootPath }; + }; + + const cloneRepository = async (input: CloneProjectInput): Promise => { + const url = (input.url ?? "").trim(); + if (!url) { + throw new Error("invalid_github_url"); + } + const repoRef = githubService.parseGitHubRepoFromRemoteUrl(url); + if (!repoRef) { + throw new Error("invalid_github_url"); + } + + const parentDir = (input.parentDir ?? "").trim(); + if (!parentDir.length) { + throw new Error("Parent directory is required."); + } + + const explicitName = (input.name ?? "").trim(); + const name = validateProjectName(explicitName.length > 0 ? explicitName : repoRef.name); + const rootPath = path.join(parentDir, name); + + if (fs.existsSync(rootPath) && isDirectoryNonEmpty(rootPath)) { + throw new Error("target_exists"); + } + + fs.mkdirSync(parentDir, { recursive: true }); + + const cloneRes = await runGit(["clone", url, rootPath], { + cwd: parentDir, + timeoutMs: 5 * 60_000, + }); + if (cloneRes.exitCode !== 0) { + throw new Error(cloneRes.stderr.trim() || `git clone failed (exit ${cloneRes.exitCode})`); + } + + return { rootPath }; + }; + + const listMyGitHubRepos = async (input: ListMyGitHubReposInput): Promise => { + let token: string; + try { + token = githubService.getTokenOrThrow(); + } catch (err) { + const wrapped = new Error("GitHub is not connected. Add a token in Settings.") as Error & { code?: string }; + wrapped.code = "github_not_connected"; + (wrapped as any).cause = err; + throw wrapped; + } + + const tokenPrefix = token.slice(0, 8); + const now = Date.now(); + let repos: MyGitHubRepoSummary[]; + if (cachedRepos && cachedRepos.tokenPrefix === tokenPrefix && cachedRepos.expiresAt > now) { + repos = cachedRepos.repos; + } else { + const collected: MyGitHubRepoSummary[] = []; + const perPage = 100; + const maxPages = 5; + for (let page = 1; page <= maxPages; page += 1) { + const { data } = await githubService.apiRequest>>({ + method: "GET", + path: "/user/repos", + query: { + per_page: perPage, + sort: "pushed", + affiliation: "owner,collaborator,organization_member", + page, + }, + }); + const items = Array.isArray(data) ? data : []; + for (const item of items) { + const owner = (item.owner as Record | undefined)?.login; + const fullName = item.full_name; + const repoName = item.name; + if (typeof owner !== "string" || typeof fullName !== "string" || typeof repoName !== "string") { + continue; + } + collected.push({ + owner, + name: repoName, + fullName, + isPrivate: Boolean(item.private), + pushedAt: typeof item.pushed_at === "string" ? item.pushed_at : null, + defaultBranch: typeof item.default_branch === "string" ? item.default_branch : "main", + htmlUrl: typeof item.html_url === "string" ? item.html_url : "", + cloneUrl: typeof item.clone_url === "string" ? item.clone_url : "", + sshUrl: typeof item.ssh_url === "string" ? item.ssh_url : "", + }); + } + if (items.length < perPage) break; + } + repos = collected; + cachedRepos = { tokenPrefix, expiresAt: now + REPO_LIST_CACHE_TTL_MS, repos }; + } + + const search = (input.search ?? "").trim().toLowerCase(); + const filtered = search.length > 0 + ? repos.filter((r) => r.fullName.toLowerCase().includes(search)) + : repos; + + return { repos: filtered }; + }; + + const getDefaultParentDir = (recentProjects: { rootPath: string }[]): string => { + const first = recentProjects?.[0]?.rootPath; + if (typeof first === "string" && first.length > 0) { + return path.dirname(first); + } + return path.join(os.homedir(), "Projects"); + }; + + return { + createLocalProject, + cloneRepository, + listMyGitHubRepos, + getDefaultParentDir, + }; +} + +export type ProjectScaffoldService = ReturnType; diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts deleted file mode 100644 index 930712ca0..000000000 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function insertProject(db: Awaited>, projectId: string, projectRoot: string, now: string) { - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, projectRoot, path.basename(projectRoot), "main", now, now], - ); -} - -function insertLane( - db: Awaited>, - args: { - laneId: string; - projectId: string; - laneType: "primary" | "worktree" | "attached"; - worktreePath: string; - branchRef: string; - status?: "active" | "archived"; - archivedAt?: string | null; - attachedRootPath?: string | null; - }, -) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.laneId, - args.projectId, - args.laneId, - null, - args.laneType, - "main", - args.branchRef, - args.worktreePath, - args.attachedRootPath ?? null, - args.laneType === "primary" ? 1 : 0, - null, - null, - null, - null, - args.status ?? "active", - "2026-04-02T12:00:00.000Z", - args.archivedAt ?? null, - ], - ); -} - -describe("toRecentProjectSummary", () => { - it("prefers active ADE lanes over raw git worktree metadata", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-summary-")); - const gitWorktreesDir = path.join(projectRoot, ".git", "worktrees"); - fs.mkdirSync(gitWorktreesDir, { recursive: true }); - for (let index = 0; index < 18; index += 1) { - fs.mkdirSync(path.join(gitWorktreesDir, `raw-${index}`), { recursive: true }); - } - - const layout = resolveAdeLayout(projectRoot); - const managedLanePath = path.join(layout.worktreesDir, "lane-managed"); - const missingLanePath = path.join(layout.worktreesDir, "lane-missing"); - const attachedLanePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-attached-")); - fs.mkdirSync(managedLanePath, { recursive: true }); - - const db = await openKvDb(layout.dbPath, createLogger()); - const now = "2026-04-02T12:00:00.000Z"; - insertProject(db, "proj-recent", projectRoot, now); - insertLane(db, { - laneId: "lane-primary", - projectId: "proj-recent", - laneType: "primary", - worktreePath: projectRoot, - branchRef: "main", - }); - insertLane(db, { - laneId: "lane-managed", - projectId: "proj-recent", - laneType: "worktree", - worktreePath: managedLanePath, - branchRef: "feature/managed", - }); - insertLane(db, { - laneId: "lane-missing", - projectId: "proj-recent", - laneType: "worktree", - worktreePath: missingLanePath, - branchRef: "feature/missing", - }); - insertLane(db, { - laneId: "lane-attached", - projectId: "proj-recent", - laneType: "attached", - worktreePath: attachedLanePath, - attachedRootPath: attachedLanePath, - branchRef: "feature/attached", - }); - insertLane(db, { - laneId: "lane-archived", - projectId: "proj-recent", - laneType: "worktree", - worktreePath: path.join(layout.worktreesDir, "lane-archived"), - branchRef: "feature/archived", - status: "archived", - archivedAt: now, - }); - db.close(); - - const inspection = inspectRecentProject({ - rootPath: projectRoot, - displayName: "demo", - lastOpenedAt: now, - }); - const summary = inspection.summary; - - expect(summary.exists).toBe(true); - expect(summary.laneCount).toBe(3); - expect(inspection.projectId).toBe("proj-recent"); - expect(inspection.defaultBaseRef).toBe("main"); - }); - - it("falls back to git worktree metadata when no ADE lane registry exists", () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-git-fallback-")); - fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-b"), { recursive: true }); - - const summary = toRecentProjectSummary({ - rootPath: projectRoot, - displayName: "demo", - lastOpenedAt: "2026-04-02T12:00:00.000Z", - }); - - expect(summary.laneCount).toBe(3); - }); - - it("falls back to git worktree metadata when ADE only has archived lanes", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-archived-fallback-")); - fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); - - const layout = resolveAdeLayout(projectRoot); - const db = await openKvDb(layout.dbPath, createLogger()); - const now = "2026-04-02T12:00:00.000Z"; - insertProject(db, "proj-recent-archived", projectRoot, now); - insertLane(db, { - laneId: "lane-primary-archived", - projectId: "proj-recent-archived", - laneType: "primary", - worktreePath: projectRoot, - branchRef: "main", - status: "archived", - archivedAt: now, - }); - db.close(); - - const summary = toRecentProjectSummary({ - rootPath: projectRoot, - displayName: "demo", - lastOpenedAt: now, - }); - - expect(summary.laneCount).toBe(2); - }); -}); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 9446c2b8e..7c238797d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -406,6 +406,14 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + CreateProjectInput, + CreateProjectResult, + CloneProjectInput, + CloneProjectResult, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + PublishProjectInput, + PublishProjectResult, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -735,6 +743,9 @@ declare global { reorderRecent: ( orderedPaths: string[], ) => Promise; + createLocal: (input: CreateProjectInput) => Promise; + clone: (input: CloneProjectInput) => Promise; + getDefaultParentDir: () => Promise; getSnapshot: () => Promise; initializeOrRepair: () => Promise; runIntegrityCheck: () => Promise; @@ -1568,6 +1579,8 @@ declare global { detectRepo: () => Promise<{ owner: string; name: string } | null>; listRepoLabels: (args: { owner: string; name: string }) => Promise>; listRepoCollaborators: (args: { owner: string; name: string }) => Promise>; + listMyRepos: (input?: ListMyGitHubReposInput) => Promise; + publishCurrentProject: (input: PublishProjectInput) => Promise; onStatusChanged: (cb: (status: GitHubStatus) => void) => () => void; }; prs: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c25f12a9a..409f52a81 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -342,6 +342,14 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + CreateProjectInput, + CreateProjectResult, + CloneProjectInput, + CloneProjectResult, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + PublishProjectInput, + PublishProjectResult, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -1109,6 +1117,12 @@ contextBridge.exposeInMainWorld("ade", { orderedPaths: string[], ): Promise => ipcRenderer.invoke(IPC.projectReorderRecent, { orderedPaths }), + createLocal: async (input: CreateProjectInput): Promise => + ipcRenderer.invoke(IPC.projectCreateLocal, input), + clone: async (input: CloneProjectInput): Promise => + ipcRenderer.invoke(IPC.projectClone, input), + getDefaultParentDir: async (): Promise => + ipcRenderer.invoke(IPC.projectGetDefaultParentDir), getSnapshot: async (): Promise => ipcRenderer.invoke(IPC.projectStateGetSnapshot), initializeOrRepair: async (): Promise => @@ -2845,6 +2859,10 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.githubListRepoLabels, args), listRepoCollaborators: async (args: { owner: string; name: string }): Promise> => ipcRenderer.invoke(IPC.githubListRepoCollaborators, args), + listMyRepos: async (input: ListMyGitHubReposInput = {}): Promise => + ipcRenderer.invoke(IPC.githubListMyRepos, input), + publishCurrentProject: async (input: PublishProjectInput): Promise => + clearAround(() => githubStatusCache.clear(), () => ipcRenderer.invoke(IPC.githubPublishCurrentProject, input)), onStatusChanged: (cb: (status: GitHubStatus) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: GitHubStatus) => { githubStatusCache.clear(); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 1580acc9e..ffb9744b1 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -3,6 +3,7 @@ import * as Dialog from "@radix-ui/react-dialog"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { + ArrowLeft, ArrowRight, CircleNotch, Clock, @@ -12,6 +13,7 @@ import { MagnifyingGlass, Stack, Warning, + X, } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { useNavigate } from "react-router-dom"; @@ -22,8 +24,25 @@ import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; import { readStoredPrsRoute } from "../prs/prsRouteState"; - -export type CommandPaletteIntent = "default" | "project-browse"; +import { AddProjectChooser } from "../projects/AddProjectChooser"; +import { CloneProjectForm } from "../projects/CloneProjectForm"; +import { CreateProjectForm } from "../projects/CreateProjectForm"; +import { ProjectActionSuccess } from "../projects/ProjectActionSuccess"; + +export type CommandPaletteIntent = + | "default" + | "project-browse" + | "project-add" + | "project-create" + | "project-clone"; + +type CommandPaletteMode = CommandPaletteIntent | "project-success"; + +type ProjectActionOutcome = { + verb: "Created" | "Cloned"; + displayName: string; + rootPath: string; +}; type Command = { id: string; @@ -143,7 +162,8 @@ export function CommandPalette({ const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); const hasActiveProject = Boolean(project?.rootPath); - const [mode, setMode] = useState("default"); + const [mode, setMode] = useState("default"); + const [actionOutcome, setActionOutcome] = useState(null); const [q, setQ] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); const [browseInput, setBrowseInput] = useState(defaultBrowseInput(project?.rootPath)); @@ -173,6 +193,22 @@ export function CommandPalette({ setBrowseSelectedIdx(0); }, [project?.rootPath]); + const startProjectAdd = useCallback(() => { + setMode("project-add"); + setQ(""); + setActionOutcome(null); + }, []); + + const startProjectCreate = useCallback(() => { + setMode("project-create"); + setActionOutcome(null); + }, []); + + const startProjectClone = useCallback(() => { + setMode("project-clone"); + setActionOutcome(null); + }, []); + useEffect(() => { if (!open) { setMode("default"); @@ -182,6 +218,7 @@ export function CommandPalette({ setBrowseLoading(false); setOpenProjectPending(false); setSystemPickerPending(false); + setActionOutcome(null); return; } @@ -190,11 +227,26 @@ export function CommandPalette({ return; } + if (intent === "project-add") { + startProjectAdd(); + return; + } + + if (intent === "project-create") { + startProjectCreate(); + return; + } + + if (intent === "project-clone") { + startProjectClone(); + return; + } + setMode("default"); setQ(""); setSelectedIdx(0); setBrowseError(null); - }, [intent, open, startProjectBrowse]); + }, [intent, open, startProjectAdd, startProjectBrowse, startProjectClone, startProjectCreate]); useEffect(() => { if (!open || mode !== "project-browse") return; @@ -215,6 +267,22 @@ export function CommandPalette({ closeOnRun: false, run: startProjectBrowse, }, + { + id: "project-create", + title: "Create new project", + hint: "New folder, git init, ready to go", + group: "Projects", + closeOnRun: false, + run: startProjectCreate, + }, + { + id: "project-clone", + title: "Clone from GitHub", + hint: "Paste a URL or pick from your repos", + group: "Projects", + closeOnRun: false, + run: startProjectClone, + }, { id: "go-project", title: "Go to Run", shortcut: "G 1", group: "Navigation", run: () => navigate("/project") }, { id: "go-lanes", title: "Go to Lanes", shortcut: "G L", group: "Navigation", run: () => navigate("/lanes") }, { id: "go-files", title: "Go to Files", shortcut: "G F", group: "Navigation", run: () => navigate("/files") }, @@ -315,11 +383,28 @@ export function CommandPalette({ ]; if (!hasActiveProject) { - return next.filter((command) => command.id === "project-browse" || command.id === "go-project" || command.id === "ping"); + return next.filter( + (command) => + command.id === "project-browse" || + command.id === "project-create" || + command.id === "project-clone" || + command.id === "go-project" || + command.id === "ping", + ); } return next; - }, [hasActiveProject, lanes, navigate, project?.rootPath, selectLane, selectedLaneId, startProjectBrowse]); + }, [ + hasActiveProject, + lanes, + navigate, + project?.rootPath, + selectLane, + selectedLaneId, + startProjectBrowse, + startProjectClone, + startProjectCreate, + ]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); @@ -700,15 +785,72 @@ export function CommandPalette({ ); const isBrowsing = mode === "project-browse"; - const resultHeightClass = isBrowsing ? "h-[620px] max-h-[86vh]" : "max-h-[400px]"; - const widthClass = isBrowsing ? "w-[1080px]" : "w-[680px]"; + const isAddFlow = + mode === "project-add" || + mode === "project-create" || + mode === "project-clone" || + mode === "project-success"; + const isWideAddFlow = mode === "project-clone"; + const resultHeightClass = isBrowsing + ? "h-[620px] max-h-[86vh]" + : isAddFlow + ? "max-h-[86vh]" + : "max-h-[400px]"; + const widthClass = isBrowsing + ? "w-[1080px]" + : isWideAddFlow + ? "w-[820px]" + : isAddFlow + ? "w-[640px]" + : "w-[680px]"; const positionClass = isBrowsing ? "fixed inset-0 z-[130] m-auto" + : isAddFlow + ? "fixed inset-0 z-[130] m-auto h-fit" : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; const inputPlaceholder = isBrowsing ? "Paste a path, type to filter, or drop a folder anywhere…" : "Search commands..."; + const handleProjectActionSuccess = useCallback( + (verb: "Created" | "Cloned", result: { rootPath: string; displayName: string }) => { + setActionOutcome({ verb, displayName: result.displayName, rootPath: result.rootPath }); + setMode("project-success"); + }, + [], + ); + + const handleSuccessOpen = useCallback(async () => { + if (!actionOutcome) { + onOpenChange(false); + return; + } + try { + await switchProjectToPath(actionOutcome.rootPath); + } catch (error) { + console.error("Failed to open new project", error); + } + onOpenChange(false); + }, [actionOutcome, onOpenChange, switchProjectToPath]); + + const handleSuccessStay = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const addFlowTitle = + mode === "project-add" + ? "Add a project" + : mode === "project-create" + ? "Create a new project" + : mode === "project-clone" + ? "Clone from GitHub" + : actionOutcome + ? `${actionOutcome.verb}!` + : ""; + + const showAddFlowBack = + mode === "project-create" || mode === "project-clone" || mode === "project-success"; + return ( @@ -784,48 +926,131 @@ export function CommandPalette({ /> )} - {mode === "project-browse" ? "Project browser" : "Command palette"} + {mode === "project-browse" + ? "Project browser" + : isAddFlow + ? addFlowTitle + : "Command palette"} {mode === "project-browse" ? "Browse folders in ADE and open a Git repository without leaving the app." + : isAddFlow + ? "Open, create, or clone a project." : "Search ADE commands and jump to actions quickly."} -
- - { - if (isBrowsing) { - setBrowseInput(event.target.value); - setBrowseSelectedIdx(0); - return; - } - setQ(event.target.value); - setSelectedIdx(0); + {isAddFlow ? ( +
+ {showAddFlowBack ? ( + + ) : ( + )} - autoFocus - /> - - ESC - -
+

+ {addFlowTitle} +

+ + + +
+ ) : ( +
+ + { + if (isBrowsing) { + setBrowseInput(event.target.value); + setBrowseSelectedIdx(0); + return; + } + setQ(event.target.value); + setSelectedIdx(0); + }} + onKeyDown={isBrowsing ? handleBrowseKeyDown : handleDefaultKeyDown} + placeholder={inputPlaceholder} + className={cn( + "h-[56px] w-full bg-transparent text-[15px] text-[var(--color-fg)] outline-none placeholder:text-[var(--color-muted-fg)]", + !isBrowsing && "font-mono" + )} + autoFocus + /> + + ESC + +
+ )} - {isBrowsing ? ( + {isAddFlow ? ( +
+ {mode === "project-add" ? ( + { + if (choice === "open") { + startProjectBrowse(); + } else if (choice === "create") { + startProjectCreate(); + } else { + startProjectClone(); + } + }} + /> + ) : mode === "project-create" ? ( + setMode("project-add")} + onCreated={(result) => handleProjectActionSuccess("Created", result)} + /> + ) : mode === "project-clone" ? ( + setMode("project-add")} + onCloned={(result) => handleProjectActionSuccess("Cloned", result)} + /> + ) : mode === "project-success" && actionOutcome ? ( + { + void handleSuccessOpen(); + }} + /> + ) : null} +
+ ) : isBrowsing ? ( <>
(null); const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); + const [publishOpen, setPublishOpen] = useState(false); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); const phoneSyncPanelRef = useRef(null); @@ -351,6 +355,17 @@ export function TopBar() { isNewTabOpen !== true && Boolean(project?.rootPath); + const projectRootForRemote = workspaceProjectOpen ? project?.rootPath ?? null : null; + const { hasRemote, refresh: refreshRemote } = useGithubProjectRemote(projectRootForRemote); + const publishDefaultName = useMemo(() => { + const root = project?.rootPath; + if (!root) return ""; + const segments = root.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? ""; + }, [project?.rootPath]); + const showPublishPill = + workspaceProjectOpen && Boolean(project?.rootPath) && hasRemote === false; + const applyZoom = useCallback((pct: number) => { const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); window.ade.zoom.setLevel(displayZoomToLevel(clamped)); @@ -845,6 +860,41 @@ export function TopBar() {
+ {showPublishPill ? ( + + + + ) : null} + {projectTransitionLabel ? (
+ { + refreshRemote(); + }} + /> + {/* Zoom controls */}
void; +}; + +type Tile = { + mode: AddProjectChooserMode; + label: string; + tagline: string; + Icon: typeof FolderOpen; + iconWeight: "regular" | "fill" | "duotone" | "bold"; + iconSize: number; + /** Hex used for the icon glow + accent border. */ + hue: string; + /** Soft inner gradient pair. */ + bgFrom: string; + bgTo: string; +}; + +const TILES: readonly Tile[] = [ + { + mode: "open", + label: "OPEN", + tagline: "a folder you have", + Icon: FolderOpen, + iconWeight: "duotone", + iconSize: 38, + hue: "#60A5FA", + bgFrom: "rgba(96,165,250,0.18)", + bgTo: "rgba(96,165,250,0.04)", + }, + { + mode: "create", + label: "CREATE", + tagline: "a brand-new project", + Icon: Sparkle, + iconWeight: "fill", + iconSize: 34, + hue: "#A78BFA", + bgFrom: "rgba(167,139,250,0.22)", + bgTo: "rgba(167,139,250,0.05)", + }, + { + mode: "clone", + label: "CLONE", + tagline: "from GitHub", + Icon: GithubLogo, + iconWeight: "fill", + iconSize: 38, + hue: "#34D399", + bgFrom: "rgba(52,211,153,0.18)", + bgTo: "rgba(52,211,153,0.04)", + }, +] as const; + +export function AddProjectChooser({ onChoose }: AddProjectChooserProps) { + return ( +
+ {TILES.map((tile) => ( + + ))} +
+ ); +} + +function ChooserTile({ + tile, + onChoose, +}: { + tile: Tile; + onChoose: (mode: AddProjectChooserMode) => void; +}) { + const [hover, setHover] = useState(false); + const Icon = tile.Icon; + + const card: CSSProperties = { + position: "relative", + height: 200, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 14, + padding: 24, + borderRadius: 16, + cursor: "pointer", + userSelect: "none", + overflow: "hidden", + background: `radial-gradient(120% 90% at 50% 0%, ${tile.bgFrom}, ${tile.bgTo} 60%, transparent 100%), color-mix(in srgb, var(--color-card) 92%, ${tile.hue})`, + border: `1px solid ${ + hover + ? `color-mix(in srgb, ${tile.hue} 65%, transparent)` + : "color-mix(in srgb, var(--color-border) 80%, transparent)" + }`, + transition: "transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease", + transform: hover ? "translateY(-3px)" : "translateY(0)", + boxShadow: hover + ? `0 22px 48px -18px color-mix(in srgb, ${tile.hue} 55%, transparent), 0 0 0 1px color-mix(in srgb, ${tile.hue} 35%, transparent), inset 0 1px 0 0 color-mix(in srgb, ${tile.hue} 30%, transparent)` + : `0 6px 16px -10px rgba(0,0,0,0.4), inset 0 1px 0 0 color-mix(in srgb, ${tile.hue} 14%, transparent)`, + }; + + const iconRing: CSSProperties = { + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 64, + height: 64, + borderRadius: 18, + background: `linear-gradient(140deg, color-mix(in srgb, ${tile.hue} 30%, transparent), color-mix(in srgb, ${tile.hue} 8%, transparent))`, + boxShadow: hover + ? `inset 0 0 0 1px color-mix(in srgb, ${tile.hue} 50%, transparent), 0 8px 24px -10px color-mix(in srgb, ${tile.hue} 60%, transparent)` + : `inset 0 0 0 1px color-mix(in srgb, ${tile.hue} 25%, transparent)`, + color: tile.hue, + transition: "box-shadow 180ms ease", + }; + + const headline: CSSProperties = { + fontFamily: MONO_FONT, + fontSize: 15, + fontWeight: 700, + letterSpacing: "0.22em", + color: COLORS.textPrimary, + }; + + const tagline: CSSProperties = { + fontFamily: SANS_FONT, + fontSize: 12, + color: COLORS.textMuted, + textAlign: "center", + }; + + // Subtle ambient glow blob behind the icon for warmth. + const glow: CSSProperties = { + position: "absolute", + top: -30, + left: "50%", + transform: "translateX(-50%)", + width: 220, + height: 160, + borderRadius: "50%", + background: `radial-gradient(closest-side, color-mix(in srgb, ${tile.hue} ${hover ? 22 : 12}%, transparent), transparent 70%)`, + pointerEvents: "none", + transition: "background 180ms ease", + }; + + function handleKey(event: KeyboardEvent) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onChoose(tile.mode); + } + } + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + onFocus={() => setHover(true)} + onBlur={() => setHover(false)} + onClick={() => onChoose(tile.mode)} + onKeyDown={handleKey} + > + + + + + {tile.label} + {tile.tagline} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx new file mode 100644 index 000000000..1f85febc9 --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx @@ -0,0 +1,1102 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { + ArrowsClockwise, + CircleNotch, + FolderOpen, + GithubLogo, + Key, + LockSimple, + MagnifyingGlass, + Warning, +} from "@phosphor-icons/react"; +import { motion, AnimatePresence } from "motion/react"; +import { extractError } from "../../lib/format"; +import type { GitHubStatus, MyGitHubRepoSummary } from "../../../shared/types"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + cardStyle, + inlineBadge, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; + +export type CloneProjectFormProps = { + onCancel: () => void; + onCloned: (result: { rootPath: string; displayName: string }) => void; +}; + +type Tab = "url" | "my-repos"; + +const URL_PATTERN = /^(https?:\/\/[^\s]+|git@[^\s:]+:[^\s]+|ssh:\/\/[^\s]+)$/i; +const SLUG_PATTERN = /([^/:]+?)(?:\.git)?$/; + +const inputStyle: CSSProperties = { + height: 36, + padding: "0 12px", + fontSize: 13, + fontFamily: SANS_FONT, + color: COLORS.textPrimary, + background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + outline: "none", + width: "100%", + boxSizing: "border-box", +}; + +function deriveSlug(url: string): string { + const trimmed = url.trim(); + if (!trimmed) return ""; + const match = trimmed.match(SLUG_PATTERN); + return match?.[1]?.replace(/\.git$/i, "") ?? ""; +} + +function joinPath(parent: string, name: string): string { + if (!parent) return name; + const sep = parent.includes("\\") ? "\\" : "/"; + const trimmed = parent.endsWith("/") || parent.endsWith("\\") ? parent.slice(0, -1) : parent; + if (!name) return trimmed; + return `${trimmed}${sep}${name}`; +} + +function relativeFromNow(iso: string | null | undefined): string { + if (!iso) return ""; + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return ""; + const diffMs = Date.now() - then; + if (diffMs < 0) return "just now"; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(days / 365); + return `${years}y ago`; +} + +export function CloneProjectForm({ onCancel, onCloned }: CloneProjectFormProps) { + const [tab, setTab] = useState("url"); + const [defaultParentDir, setDefaultParentDir] = useState(""); + + useEffect(() => { + let cancelled = false; + void window.ade.project + .getDefaultParentDir() + .then((value) => { + if (cancelled) return; + setDefaultParentDir(value); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+ + {tab === "url" ? ( + + ) : ( + + )} +
+ ); +} + +function TabBar({ tab, onChange }: { tab: Tab; onChange: (tab: Tab) => void }) { + return ( +
+ onChange("url")}> + URL + + onChange("my-repos")}> + My repos + +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function UrlTab({ + defaultParentDir, + onCancel, + onCloned, +}: { + defaultParentDir: string; + onCancel: () => void; + onCloned: (result: { rootPath: string; displayName: string }) => void; +}) { + const [url, setUrl] = useState(""); + const [name, setName] = useState(""); + const [nameTouched, setNameTouched] = useState(false); + const [parentDir, setParentDir] = useState(""); + const [pickerPending, setPickerPending] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [pathExists, setPathExists] = useState(false); + + useEffect(() => { + if (!parentDir && defaultParentDir) setParentDir(defaultParentDir); + }, [defaultParentDir, parentDir]); + + const checkRequestRef = useRef(0); + + const trimmedUrl = url.trim(); + const trimmedName = name.trim(); + const urlValid = trimmedUrl.length > 0 && URL_PATTERN.test(trimmedUrl); + const previewPath = useMemo( + () => (parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""), + [parentDir, trimmedName], + ); + + useEffect(() => { + if (!previewPath) { + setPathExists(false); + return; + } + const requestId = ++checkRequestRef.current; + const timeout = window.setTimeout(() => { + void window.ade.project + .browseDirectories({ partialPath: previewPath }) + .then((result) => { + if (checkRequestRef.current !== requestId) return; + setPathExists(result.exactDirectoryPath === previewPath); + }) + .catch(() => { + if (checkRequestRef.current !== requestId) return; + setPathExists(false); + }); + }, 220); + return () => window.clearTimeout(timeout); + }, [previewPath]); + + const handleUrlBlur = useCallback(() => { + if (nameTouched) return; + const slug = deriveSlug(trimmedUrl); + if (slug) setName(slug); + }, [nameTouched, trimmedUrl]); + + const handleChooseParent = useCallback(async () => { + setPickerPending(true); + setError(null); + try { + const selected = await window.ade.project.chooseDirectory({ + title: "Choose parent directory", + defaultPath: parentDir || undefined, + }); + if (selected) setParentDir(selected); + } catch (err) { + setError(extractError(err)); + } finally { + setPickerPending(false); + } + }, [parentDir]); + + const canSubmit = + urlValid && trimmedName.length > 0 && parentDir.length > 0 && !pathExists && !pending; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return; + setPending(true); + setError(null); + try { + const result = await window.ade.project.clone({ + url: trimmedUrl, + parentDir, + name: trimmedName, + }); + onCloned({ rootPath: result.rootPath, displayName: trimmedName }); + } catch (err) { + setError(extractError(err)); + } finally { + setPending(false); + } + }, [canSubmit, onCloned, parentDir, trimmedName, trimmedUrl]); + + return ( +
+ + setUrl(event.target.value)} + onBlur={handleUrlBlur} + style={{ ...inputStyle, fontFamily: MONO_FONT, fontSize: 12 }} + disabled={pending} + /> + {url.length > 0 && !urlValid ? ( + URL must look like https://, git@…:…, or ssh://… + ) : null} + + + + { + setName(event.target.value); + setNameTouched(true); + }} + style={inputStyle} + disabled={pending} + /> + + + +
+
+ {parentDir || "No parent directory selected"} +
+ +
+
+ + + + {error ? {error} : null} + +
+ + +
+
+ ); +} + +function MyReposTab({ + defaultParentDir, + onCancel, + onCloned, +}: { + defaultParentDir: string; + onCancel: () => void; + onCloned: (result: { rootPath: string; displayName: string }) => void; +}) { + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [statusError, setStatusError] = useState(null); + + const loadStatus = useCallback(async () => { + setStatusLoading(true); + setStatusError(null); + try { + const next = await window.ade.github.getStatus({ forceRefresh: true }); + setStatus(next); + } catch (err) { + setStatusError(extractError(err)); + } finally { + setStatusLoading(false); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const isConnected = Boolean(status?.tokenStored && !status?.tokenDecryptionFailed); + + if (statusLoading && !status) { + return ( +
+ + Checking GitHub connection… +
+ ); + } + + if (!isConnected) { + return ( + { + void loadStatus(); + }} + onCancel={onCancel} + /> + ); + } + + return ( + + ); +} + +function ConnectGithubPrompt({ + statusError, + onConnected, + onCancel, +}: { + statusError: string | null; + onConnected: () => void; + onCancel: () => void; +}) { + const [token, setToken] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const handleSave = useCallback(async () => { + const trimmed = token.trim(); + if (!trimmed) { + setError("Enter a token"); + return; + } + setPending(true); + setError(null); + try { + await window.ade.github.setToken(trimmed); + onConnected(); + } catch (err) { + setError(extractError(err)); + } finally { + setPending(false); + } + }, [onConnected, token]); + + return ( +
+
+ + + +
+
+ Connect GitHub to browse your repos +
+
+ Paste a personal access token. Your token stays local. +
+
+
+ + + + {(error || statusError) ? ( + {error ?? statusError} + ) : null} + +
+ + +
+
+ ); +} + +function ConnectedRepoBrowser({ + defaultParentDir, + onCancel, + onCloned, +}: { + defaultParentDir: string; + onCancel: () => void; + onCloned: (result: { rootPath: string; displayName: string }) => void; +}) { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedFullName, setExpandedFullName] = useState(null); + + const requestRef = useRef(0); + + useEffect(() => { + const timeout = window.setTimeout(() => { + setDebouncedSearch(search); + }, 200); + return () => window.clearTimeout(timeout); + }, [search]); + + useEffect(() => { + const requestId = ++requestRef.current; + setLoading(true); + setError(null); + void window.ade.github + .listMyRepos({ search: debouncedSearch || undefined }) + .then((result) => { + if (requestRef.current !== requestId) return; + const sorted = [...result.repos].sort((a, b) => { + const aTime = a.pushedAt ? new Date(a.pushedAt).getTime() : 0; + const bTime = b.pushedAt ? new Date(b.pushedAt).getTime() : 0; + return bTime - aTime; + }); + setRepos(sorted); + }) + .catch((err) => { + if (requestRef.current !== requestId) return; + // Surface the raw error to console so the user can diagnose + // (e.g., scope problems, network failures) when the list is empty. + console.error("[CloneProjectForm] listMyRepos failed", err); + setError(extractError(err)); + setRepos([]); + }) + .finally(() => { + if (requestRef.current !== requestId) return; + setLoading(false); + }); + }, [debouncedSearch]); + + return ( +
+
+ + setSearch(event.target.value)} + placeholder="Filter your repositories…" + autoFocus + style={{ + flex: 1, + height: "100%", + background: "transparent", + border: "none", + outline: "none", + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 13, + }} + /> + {loading ? : null} +
+ + {error ? {error} : null} + +
+ {loading && repos.length === 0 ? ( + + ) : repos.length === 0 ? ( +
+ {debouncedSearch ? "No repositories match." : "No repositories found."} +
+ ) : ( + repos.map((repo) => ( + + setExpandedFullName((prev) => (prev === repo.fullName ? null : repo.fullName)) + } + defaultParentDir={defaultParentDir} + onCloned={onCloned} + /> + )) + )} +
+ +
+ +
+
+ ); +} + +function RepoRow({ + repo, + expanded, + onToggle, + defaultParentDir, + onCloned, +}: { + repo: MyGitHubRepoSummary; + expanded: boolean; + onToggle: () => void; + defaultParentDir: string; + onCloned: (result: { rootPath: string; displayName: string }) => void; +}) { + const [parentDir, setParentDir] = useState(defaultParentDir); + const [name, setName] = useState(repo.name); + const [pickerPending, setPickerPending] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [pathExists, setPathExists] = useState(false); + + useEffect(() => { + setParentDir(defaultParentDir); + }, [defaultParentDir]); + + const checkRequestRef = useRef(0); + const trimmedName = name.trim(); + const previewPath = parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""; + + useEffect(() => { + if (!expanded || !previewPath) { + setPathExists(false); + return; + } + const requestId = ++checkRequestRef.current; + const timeout = window.setTimeout(() => { + void window.ade.project + .browseDirectories({ partialPath: previewPath }) + .then((result) => { + if (checkRequestRef.current !== requestId) return; + setPathExists(result.exactDirectoryPath === previewPath); + }) + .catch(() => { + if (checkRequestRef.current !== requestId) return; + setPathExists(false); + }); + }, 220); + return () => window.clearTimeout(timeout); + }, [expanded, previewPath]); + + const handleChooseParent = useCallback(async () => { + setPickerPending(true); + setError(null); + try { + const selected = await window.ade.project.chooseDirectory({ + title: "Choose parent directory", + defaultPath: parentDir || undefined, + }); + if (selected) setParentDir(selected); + } catch (err) { + setError(extractError(err)); + } finally { + setPickerPending(false); + } + }, [parentDir]); + + const canClone = + trimmedName.length > 0 && parentDir.length > 0 && !pathExists && !pending; + + const handleClone = useCallback(async () => { + if (!canClone) return; + setPending(true); + setError(null); + try { + const result = await window.ade.project.clone({ + url: repo.cloneUrl, + parentDir, + name: trimmedName, + }); + onCloned({ rootPath: result.rootPath, displayName: trimmedName }); + } catch (err) { + setError(extractError(err)); + } finally { + setPending(false); + } + }, [canClone, onCloned, parentDir, repo.cloneUrl, trimmedName]); + + const visibilityChip: CSSProperties = repo.isPrivate + ? inlineBadge(COLORS.accent, { padding: "2px 6px", fontSize: 10 }) + : inlineBadge(COLORS.success, { padding: "2px 6px", fontSize: 10 }); + + const relPushed = relativeFromNow(repo.pushedAt); + + return ( +
+ + + + {expanded ? ( + +
+ + setName(event.target.value)} + style={inputStyle} + disabled={pending} + /> + + + +
+
+ {parentDir || "Choose a parent directory"} +
+ +
+
+ + + + {error ? {error} : null} + +
+ + +
+
+
+ ) : null} +
+
+ ); +} + +function RepoSkeleton() { + return ( +
+ +
Loading your repositories…
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function PathPreview({ path, exists }: { path: string; exists: boolean }) { + if (!path) return null; + return ( +
+
WILL BE CLONED TO
+
+ {path} +
+ {exists ? ( +
+ + Path already exists +
+ ) : null} +
+ ); +} + +function InlineHint({ + children, + tone, +}: { + children: React.ReactNode; + tone: "danger" | "muted"; +}) { + return ( + + {tone === "danger" ? : null} + {children} + + ); +} diff --git a/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx new file mode 100644 index 000000000..d4a62c7ba --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/CreateProjectForm.tsx @@ -0,0 +1,352 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { CircleNotch, FolderOpen, Warning } from "@phosphor-icons/react"; +import { extractError } from "../../lib/format"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + cardStyle, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; + +export type CreateProjectFormProps = { + onCancel: () => void; + onCreated: (result: { rootPath: string; displayName: string }) => void; +}; + +type NameValidation = { ok: true } | { ok: false; reason: string }; + +function validateName(rawName: string): NameValidation { + const name = rawName.trim(); + if (name.length === 0) return { ok: false, reason: "Enter a project name" }; + if (name.length > 100) return { ok: false, reason: "Name must be 100 characters or fewer" }; + if (name.startsWith(".")) return { ok: false, reason: "Name cannot start with a dot" }; + if (/[/\\]/.test(name)) return { ok: false, reason: "Name cannot contain / or \\" }; + return { ok: true }; +} + +function joinPath(parent: string, name: string): string { + if (!parent) return name; + const sep = parent.includes("\\") ? "\\" : "/"; + const trimmed = parent.endsWith("/") || parent.endsWith("\\") ? parent.slice(0, -1) : parent; + if (!name) return trimmed; + return `${trimmed}${sep}${name}`; +} + +const inputStyle: CSSProperties = { + height: 36, + padding: "0 12px", + fontSize: 13, + fontFamily: SANS_FONT, + color: COLORS.textPrimary, + background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + outline: "none", + width: "100%", + boxSizing: "border-box", +}; + +export function CreateProjectForm({ onCancel, onCreated }: CreateProjectFormProps) { + const [name, setName] = useState(""); + const [parentDir, setParentDir] = useState(""); + const [parentDirLoading, setParentDirLoading] = useState(true); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [pathExists, setPathExists] = useState(false); + const [pickerPending, setPickerPending] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); + + const checkRequestRef = useRef(0); + + useEffect(() => { + let cancelled = false; + void window.ade.project + .getDefaultParentDir() + .then((value) => { + if (cancelled) return; + setParentDir(value); + }) + .catch(() => { + if (cancelled) return; + }) + .finally(() => { + if (cancelled) return; + setParentDirLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const validation = useMemo(() => validateName(name), [name]); + const trimmedName = name.trim(); + const previewPath = useMemo( + () => (parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""), + [parentDir, trimmedName], + ); + + useEffect(() => { + if (!previewPath || !validation.ok) { + setPathExists(false); + return; + } + const requestId = ++checkRequestRef.current; + const timeout = window.setTimeout(() => { + void window.ade.project + .browseDirectories({ partialPath: previewPath }) + .then((result) => { + if (checkRequestRef.current !== requestId) return; + setPathExists(Boolean(result.exactDirectoryPath === previewPath)); + }) + .catch(() => { + if (checkRequestRef.current !== requestId) return; + setPathExists(false); + }); + }, 220); + return () => { + window.clearTimeout(timeout); + }; + }, [previewPath, validation.ok]); + + const showNameError = + !validation.ok && (submitAttempted || name.length > 0); + const canSubmit = + validation.ok && parentDir.length > 0 && !pathExists && !pending && !parentDirLoading; + + const handleChooseParent = useCallback(async () => { + setPickerPending(true); + setError(null); + try { + const selected = await window.ade.project.chooseDirectory({ + title: "Choose parent directory", + defaultPath: parentDir || undefined, + }); + if (selected) { + setParentDir(selected); + } + } catch (err) { + setError(extractError(err)); + } finally { + setPickerPending(false); + } + }, [parentDir]); + + const handleSubmit = useCallback(async () => { + setSubmitAttempted(true); + if (!validation.ok || !parentDir || pathExists) return; + setPending(true); + setError(null); + try { + const result = await window.ade.project.createLocal({ + name: trimmedName, + parentDir, + }); + onCreated({ rootPath: result.rootPath, displayName: trimmedName }); + } catch (err) { + setError(extractError(err)); + } finally { + setPending(false); + } + }, [onCreated, parentDir, pathExists, trimmedName, validation.ok]); + + return ( +
+ + setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && canSubmit) { + event.preventDefault(); + void handleSubmit(); + } + }} + style={inputStyle} + disabled={pending} + /> + {showNameError && !validation.ok ? ( + {validation.reason} + ) : null} + + + +
+
+ {parentDirLoading ? "Loading…" : parentDir || "No parent directory selected"} +
+ +
+
+ + + + {error ? {error} : null} + +
+ + +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function PathPreview({ path, exists }: { path: string; exists: boolean }) { + if (!path) return null; + return ( +
+
WILL BE CREATED AT
+
+ {path} +
+ {exists ? ( +
+ + Path already exists +
+ ) : null} +
+ ); +} + +function InlineHint({ + children, + tone, +}: { + children: React.ReactNode; + tone: "danger" | "muted"; +}) { + return ( + + {tone === "danger" ? : null} + {children} + + ); +} diff --git a/apps/desktop/src/renderer/components/projects/ProjectActionSuccess.tsx b/apps/desktop/src/renderer/components/projects/ProjectActionSuccess.tsx new file mode 100644 index 000000000..3c802bd6d --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/ProjectActionSuccess.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { CheckCircle } from "@phosphor-icons/react"; +import { + COLORS, + MONO_FONT, + SANS_FONT, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; + +export type ProjectActionSuccessProps = { + verb: "Created" | "Cloned"; + displayName: string; + rootPath: string; + onStay: () => void; + onOpen: () => void; +}; + +export function ProjectActionSuccess({ + verb, + displayName, + rootPath, + onStay, + onOpen, +}: ProjectActionSuccessProps) { + return ( +
+
+ +
+ +
+
+ {verb}{" "} + {displayName} +
+
+ {rootPath} +
+
+ +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx b/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx new file mode 100644 index 000000000..d6acf6a05 --- /dev/null +++ b/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx @@ -0,0 +1,755 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, +} from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { AnimatePresence, motion } from "motion/react"; +import { + ArrowSquareOut, + CheckCircle, + CircleNotch, + GithubLogo, + Key, + LinkBreak, + Warning, + X, +} from "@phosphor-icons/react"; +import type { PublishProjectResult } from "../../../shared/types"; +import { extractError } from "../../lib/format"; +import { fadeScale } from "../../lib/motion"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; + +const GITHUB_CLASSIC_TOKEN_NEW_URL = + "https://github.com/settings/tokens/new?description=ADE%20desktop%20PR%20workflows&scopes=repo,workflow"; + +export type PublishToGitHubDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultRepoName: string; + onPublished: () => void; +}; + +type PublishError = { code: string; message: string }; + +type NameValidation = { ok: true } | { ok: false; reason: string }; + +function validateRepoName(rawName: string): NameValidation { + const name = rawName.trim(); + if (name.length === 0) return { ok: false, reason: "Enter a repository name" }; + if (name.length > 100) return { ok: false, reason: "Name must be 100 characters or fewer" }; + if (name.startsWith(".")) return { ok: false, reason: "Name cannot start with a dot" }; + if (name.includes("..")) return { ok: false, reason: "Name cannot contain '..'" }; + if (!/^[A-Za-z0-9._-]+$/.test(name)) { + return { ok: false, reason: "Use letters, numbers, '-', '_' or '.'" }; + } + return { ok: true }; +} + +function extractCodeFromMessage(err: unknown): string | null { + const direct = (err as { code?: unknown } | null)?.code; + if (typeof direct === "string" && direct.length > 0) return direct; + const message = err instanceof Error ? err.message : String(err ?? ""); + const match = message.match(/^([a-z][a-z0-9_]*)\s*:\s*/i); + return match?.[1] ?? null; +} + +function detectTokenType(token: string): "classic" | "fine-grained" | "unknown" { + if (token.startsWith("github_pat_")) return "fine-grained"; + if (token.startsWith("ghp_")) return "classic"; + return "unknown"; +} + +const inputStyle: CSSProperties = { + height: 36, + padding: "0 12px", + fontSize: 13, + fontFamily: SANS_FONT, + color: COLORS.textPrimary, + background: "color-mix(in srgb, var(--color-fg) 4%, transparent)", + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + outline: "none", + width: "100%", + boxSizing: "border-box", +}; + +const textareaStyle: CSSProperties = { + ...inputStyle, + height: "auto", + minHeight: 64, + paddingTop: 8, + paddingBottom: 8, + resize: "vertical", +}; + +export function PublishToGitHubDialog({ + open, + onOpenChange, + defaultRepoName, + onPublished, +}: PublishToGitHubDialogProps) { + const [name, setName] = useState(defaultRepoName); + const [description, setDescription] = useState(""); + const [isPrivate, setIsPrivate] = useState(true); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [connectMode, setConnectMode] = useState(false); + const [tokenDraft, setTokenDraft] = useState(""); + const [tokenSaving, setTokenSaving] = useState(false); + const [tokenError, setTokenError] = useState(null); + + // Reset all local state whenever the dialog opens. + useEffect(() => { + if (!open) return; + setName(defaultRepoName); + setDescription(""); + setIsPrivate(true); + setPending(false); + setError(null); + setSuccess(null); + setConnectMode(false); + setTokenDraft(""); + setTokenSaving(false); + setTokenError(null); + }, [open, defaultRepoName]); + + const validation = useMemo(() => validateRepoName(name), [name]); + const trimmedName = name.trim(); + const canSubmit = validation.ok && !pending; + + const handleSubmit = useCallback(async () => { + if (!validation.ok || pending) return; + setPending(true); + setError(null); + try { + const result = await window.ade.github.publishCurrentProject({ + name: trimmedName, + description: description.trim() || undefined, + isPrivate, + }); + setSuccess(result); + } catch (err) { + const code = extractCodeFromMessage(err); + if (code === "github_not_connected") { + setConnectMode(true); + } else if (code === "remote_already_exists") { + setError({ + code, + message: "This project already has a GitHub remote.", + }); + } else { + setError({ + code: code ?? "unknown", + message: extractError(err), + }); + } + } finally { + setPending(false); + } + }, [description, isPrivate, pending, trimmedName, validation.ok]); + + const handleSaveToken = useCallback(async () => { + const token = tokenDraft.trim(); + if (!token) { + setTokenError("Paste a personal access token first."); + return; + } + setTokenSaving(true); + setTokenError(null); + try { + await window.ade.github.setToken(token); + setTokenDraft(""); + setConnectMode(false); + } catch (err) { + setTokenError(extractError(err)); + } finally { + setTokenSaving(false); + } + }, [tokenDraft]); + + const handleOpenOnGitHub = useCallback(() => { + if (!success) return; + void window.ade.app.openExternal(success.htmlUrl); + }, [success]); + + const handleDone = useCallback(() => { + onPublished(); + onOpenChange(false); + }, [onOpenChange, onPublished]); + + const handleOpenTokenLink = useCallback(() => { + void window.ade.app.openExternal(GITHUB_CLASSIC_TOKEN_NEW_URL); + }, []); + + const headerTitle = success + ? "Publish to GitHub" + : connectMode + ? "Connect GitHub" + : "Publish to GitHub"; + + return ( + + + {open && ( + + + + + event.preventDefault()}> + +
+
+ + + {headerTitle} + +
+ + + +
+ + Publish the current project to GitHub. + + +
+ {success ? ( + + ) : connectMode ? ( + setConnectMode(false)} + /> + ) : ( + onOpenChange(false)} + onSubmit={handleSubmit} + /> + )} +
+
+
+
+ )} +
+
+ ); +} + +function FormBody({ + name, + onNameChange, + description, + onDescriptionChange, + isPrivate, + onPrivateChange, + validation, + pending, + canSubmit, + error, + onCancel, + onSubmit, +}: { + name: string; + onNameChange: (value: string) => void; + description: string; + onDescriptionChange: (value: string) => void; + isPrivate: boolean; + onPrivateChange: (value: boolean) => void; + validation: NameValidation; + pending: boolean; + canSubmit: boolean; + error: PublishError | null; + onCancel: () => void; + onSubmit: () => void; +}) { + return ( +
+ + onNameChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && canSubmit) { + event.preventDefault(); + onSubmit(); + } + }} + style={inputStyle} + disabled={pending} + /> + {!validation.ok && name.length > 0 ? ( + {validation.reason} + ) : null} + + + +