diff --git a/packages/app/e2e/add-project-flow.spec.ts b/packages/app/e2e/add-project-flow.spec.ts new file mode 100644 index 000000000..acf9826b5 --- /dev/null +++ b/packages/app/e2e/add-project-flow.spec.ts @@ -0,0 +1,316 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect, type Page } from "./fixtures"; +import { + addProjectFlow, + addProjectFlowBack, + addProjectFlowHost, + addProjectFlowInput, + addProjectFlowMethod, + chooseAddProjectMethod, + expectAddProjectPage, + openAddProjectFlow, + waitForConnectedHost, +} from "./helpers/add-project-flow"; +import { gotoAppShell } from "./helpers/app"; +import { buildSeededHost } from "./helpers/daemon-registry"; +import { addOfflineHostAndReload } from "./helpers/hosts"; +import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; +import { expectOpenedProject } from "./helpers/project-picker-ui"; +import { connectSeedClient } from "./helpers/seed-client"; + +const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; +const SECONDARY_HOST_ID = "add-project-flow-secondary"; +const SECONDARY_HOST_LABEL = "Secondary Host"; + +async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise { + const registryEntry = buildSeededHost({ + serverId: host.serverId, + label: SECONDARY_HOST_LABEL, + endpoint: `127.0.0.1:${host.port}`, + nowIso: new Date().toISOString(), + }); + await page.evaluate( + ({ key, entry }) => { + localStorage.setItem(key, JSON.stringify([entry])); + }, + { key: EXTRA_HOSTS_KEY, entry: registryEntry }, + ); + await page.reload(); +} + +async function expectProjectDirectory(pathname: string): Promise { + await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true); +} + +async function removeCreatedProject( + pathname: string, + knownProjectId: string | null, +): Promise { + const client = await connectSeedClient(); + try { + let projectId = knownProjectId; + if (!projectId) { + const result = await client.addProject(pathname); + projectId = result.project?.projectId ?? null; + } + if (projectId) await client.removeProject(projectId).catch(() => undefined); + } finally { + await client.close(); + } +} + +test.describe("Add Project command-center flow", () => { + test.describe.configure({ timeout: 180_000 }); + + test("a single connected host opens directly on method selection", async ({ page }) => { + await gotoAppShell(page); + + await openAddProjectFlow(page); + + await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible(); + await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0); + }); + + test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => { + await gotoAppShell(page); + await openAddProjectFlow(page); + + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "directory-search"); + await addProjectFlowInput(page).fill("/tmp"); + + const backGlyph = addProjectFlowBack(page).locator("svg"); + const resultGlyph = addProjectFlow(page) + .locator('[data-testid^="add-project-flow-path-"]') + .first() + .locator("svg"); + await expect(resultGlyph).toBeVisible(); + + const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([ + backGlyph.boundingBox(), + addProjectFlowInput(page).boundingBox(), + resultGlyph.boundingBox(), + page.getByTestId("add-project-flow-title").boundingBox(), + page.getByTestId("add-project-flow-results").boundingBox(), + page.getByTestId("add-project-flow-footer").boundingBox(), + ]); + expect(backBox).not.toBeNull(); + expect(inputBox).not.toBeNull(); + expect(resultBox).not.toBeNull(); + expect(titleBox).not.toBeNull(); + expect(resultsBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return; + + expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2); + expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2); + expect(titleBox.height).toBeLessThanOrEqual(24); + expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1); + }); + + test("an offline extra host neither appears nor forces host selection", async ({ page }) => { + await gotoAppShell(page); + await addOfflineHostAndReload(page, { + serverId: "add-project-flow-offline", + label: "Offline Host", + }); + + await openAddProjectFlow(page); + + await expect(addProjectFlowHost(page, "add-project-flow-offline")).toHaveCount(0); + await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible(); + }); + + test.describe("with two connected hosts", () => { + let secondaryHost: IsolatedHostDaemon; + + test.beforeAll(async () => { + secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID); + }); + + test.afterAll(async () => { + await secondaryHost?.close(); + }); + + test("keyboard selection chooses the second host", async ({ page }) => { + await gotoAppShell(page); + await addConnectedHostAndReload(page, secondaryHost); + await waitForConnectedHost(page, { + serverId: SECONDARY_HOST_ID, + endpoint: `localhost:${secondaryHost.port}`, + }); + await openAddProjectFlow(page, "host"); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + + await expectAddProjectPage(page, "method"); + await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL); + }); + + test("Escape and Back restore page input and active selection before closing at the root", async ({ + page, + }) => { + await gotoAppShell(page); + await addConnectedHostAndReload(page, secondaryHost); + await waitForConnectedHost(page, { + serverId: SECONDARY_HOST_ID, + endpoint: `localhost:${secondaryHost.port}`, + }); + await openAddProjectFlow(page, "host"); + + await addProjectFlowInput(page).fill("o"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "method"); + + await addProjectFlowInput(page).fill("new"); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "new-directory-parent"); + await page.keyboard.press("Escape"); + + await expectAddProjectPage(page, "method"); + await expect(addProjectFlowInput(page)).toHaveValue("new"); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "new-directory-parent"); + await addProjectFlowBack(page).click(); + + await expectAddProjectPage(page, "method"); + await addProjectFlowBack(page).click(); + await expectAddProjectPage(page, "host"); + await expect(addProjectFlowInput(page)).toHaveValue("o"); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "method"); + await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL); + + await page.keyboard.press("Escape"); + await expectAddProjectPage(page, "host"); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).not.toBeVisible(); + }); + + test("New directory creates a Project on the selected remote host", async ({ page }) => { + const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-remote-project-")); + const directoryName = `remote-${randomUUID().slice(0, 8)}`; + const directoryPath = path.join(parentDirectory, directoryName); + + try { + await gotoAppShell(page); + await addConnectedHostAndReload(page, secondaryHost); + await waitForConnectedHost(page, { + serverId: SECONDARY_HOST_ID, + endpoint: `localhost:${secondaryHost.port}`, + }); + await openAddProjectFlow(page, "host"); + await addProjectFlowHost(page, SECONDARY_HOST_ID).click(); + await expectAddProjectPage(page, "method"); + + await expect(addProjectFlowMethod(page, "new-directory")).toContainText( + `Create an empty directory on ${SECONDARY_HOST_LABEL}`, + ); + await chooseAddProjectMethod(page, "new-directory"); + await addProjectFlowInput(page).fill(parentDirectory); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "new-directory-name"); + await page.keyboard.type(directoryName); + await page.keyboard.press("Enter"); + + await expectOpenedProject(page, directoryName); + await expectProjectDirectory(directoryPath); + } finally { + await rm(parentDirectory, { recursive: true, force: true }); + } + }); + }); + + test("keyboard directory search adds the selected Project", async ({ + page, + projectPickerFixture, + }) => { + await gotoAppShell(page); + await openAddProjectFlow(page); + + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "directory-search"); + await page.keyboard.type(projectPickerFixture.fuzzyQuery); + await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, { + timeout: 30_000, + }); + await page.keyboard.press("Enter"); + + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); + }); + + test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => { + await gotoAppShell(page); + await openAddProjectFlow(page); + + await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub"); + await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory"); + }); + + test("a complete repository URL remains selectable without a GitHub search result", async ({ + page, + }) => { + await gotoAppShell(page); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "github"); + + const remote = "https://github.invalid/acme/manual.git"; + await addProjectFlowInput(page).fill(remote); + await expect(addProjectFlow(page).getByText("manual", { exact: true })).toBeVisible(); + await page.keyboard.press("Enter"); + + await expectAddProjectPage(page, "github-location"); + await expect(addProjectFlow(page).getByTestId("add-project-flow-title")).toContainText( + "manual", + ); + await addProjectFlowBack(page).click(); + await expect(addProjectFlowInput(page)).toHaveValue(remote); + }); + + test("New directory validates the name, restores parent and name state, then creates a Project", async ({ + page, + }) => { + const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-new-project-")); + const directoryName = `created-${randomUUID().slice(0, 8)}`; + const directoryPath = path.join(parentDirectory, directoryName); + let projectId: string | null = null; + + try { + await gotoAppShell(page); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "new-directory"); + + await page.keyboard.type(parentDirectory); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "new-directory-name"); + await page.keyboard.type("../invalid"); + await page.keyboard.press("Enter"); + + const error = page.getByTestId("add-project-flow-error"); + await expect(error).toBeVisible(); + await expect(error).toContainText(/name|separator|directory/i); + await expectAddProjectPage(page, "new-directory-name"); + + await addProjectFlowInput(page).fill(directoryName); + await addProjectFlowBack(page).click(); + await expectAddProjectPage(page, "new-directory-parent"); + await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory); + await page.keyboard.press("Enter"); + await expectAddProjectPage(page, "new-directory-name"); + await expect(addProjectFlowInput(page)).toHaveValue(directoryName); + await page.keyboard.press("Enter"); + + projectId = await expectOpenedProject(page, directoryName); + await expectProjectDirectory(directoryPath); + } finally { + await removeCreatedProject(directoryPath, projectId).catch(() => undefined); + await rm(parentDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/app/e2e/add-project-github.real.spec.ts b/packages/app/e2e/add-project-github.real.spec.ts new file mode 100644 index 000000000..1219d7f74 --- /dev/null +++ b/packages/app/e2e/add-project-github.real.spec.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { + addProjectFlow, + addProjectFlowBack, + addProjectFlowInput, + chooseAddProjectMethod, + expectAddProjectPage, + openAddProjectFlow, +} from "./helpers/add-project-flow"; +import { gotoAppShell } from "./helpers/app"; +import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures"; +import { expectOpenedProject } from "./helpers/project-picker-ui"; +import { connectSeedClient } from "./helpers/seed-client"; + +test.describe("Add Project GitHub flow", () => { + test.describe.configure({ timeout: 300_000 }); + + test("searches the host's repositories and clones into a clearly shown final path", async ({ + page, + }) => { + test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)"); + + let repository: GhRepoFixture | null = null; + const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-github-clone-")); + let projectId: string | null = null; + + try { + repository = await createTempGithubRepo({ category: "add-project" }); + const checkoutPath = path.join(parentDirectory, repository.name); + + await gotoAppShell(page); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "github"); + + await addProjectFlowInput(page).fill("getpaseo/paseo"); + await expect(addProjectFlow(page).getByText("getpaseo/paseo", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await addProjectFlowInput(page).fill(""); + + const repositoryRow = addProjectFlow(page).getByText(repository.fullName, { exact: true }); + await expect(repositoryRow).toBeVisible({ timeout: 30_000 }); + await repositoryRow.click(); + await expectAddProjectPage(page, "github-location"); + + await addProjectFlowInput(page).fill(parentDirectory); + await expect(addProjectFlow(page)).toContainText(checkoutPath, { timeout: 30_000 }); + + await addProjectFlowBack(page).click(); + await expectAddProjectPage(page, "github-search"); + await expect(repositoryRow).toBeVisible(); + await repositoryRow.click(); + await expectAddProjectPage(page, "github-location"); + await expect(addProjectFlowInput(page)).toHaveValue(parentDirectory); + + await page.keyboard.press("Enter"); + projectId = await expectOpenedProject(page, repository.name); + await expect.poll(async () => (await stat(checkoutPath)).isDirectory()).toBe(true); + } finally { + if (projectId) { + const client = await connectSeedClient(); + try { + await client.removeProject(projectId).catch(() => undefined); + } finally { + await client.close(); + } + } + await repository?.cleanup(); + await rm(parentDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index ea6b6e59a..2344fd597 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -2,6 +2,11 @@ import path from "node:path"; import { existsSync } from "node:fs"; import { test, expect, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; +import { + addProjectFlowInput, + chooseAddProjectMethod, + openAddProjectFlow, +} from "./helpers/add-project-flow"; import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; @@ -46,10 +51,10 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise< } async function addProjectFromPicker(page: Page, projectPath: string): Promise { - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); - const input = page.getByTestId("project-picker-input"); - await expect(input).toBeVisible({ timeout: 30_000 }); + const input = addProjectFlowInput(page); await input.fill(projectPath); await page.keyboard.press("Enter"); @@ -78,10 +83,10 @@ test.describe("Project picker search", () => { }) => { await gotoAppShell(page); await waitForSidebarProjectListReady(page); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); - const input = page.getByTestId("project-picker-input"); - await expect(input).toBeVisible({ timeout: 30_000 }); + const input = addProjectFlowInput(page); await input.fill(projectPickerFixture.fuzzyQuery); const suggestion = page.getByText(projectPickerFixture.projectName, { exact: false }).first(); @@ -97,14 +102,14 @@ test.describe("Project picker search", () => { }) => { await gotoAppShell(page); await waitForSidebarProjectListReady(page); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); - const input = page.getByTestId("project-picker-input"); - await expect(input).toBeVisible({ timeout: 30_000 }); + const input = addProjectFlowInput(page); await input.fill("paseo-loading-state-no-match"); await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0); - await expect(page.getByText("Searching...", { exact: true })).toBeVisible(); + await expect(page.getByText("Loading...", { exact: true })).toBeVisible(); }); }); diff --git a/packages/app/e2e/helpers/add-project-flow.ts b/packages/app/e2e/helpers/add-project-flow.ts new file mode 100644 index 000000000..025dca3a9 --- /dev/null +++ b/packages/app/e2e/helpers/add-project-flow.ts @@ -0,0 +1,74 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export type AddProjectFlowPage = + | "host" + | "method" + | "directory-search" + | "github-search" + | "github-location" + | "new-directory-parent" + | "new-directory-name"; + +export type AddProjectMethod = "directory-search" | "browse" | "github" | "new-directory"; + +const METHOD_DESTINATIONS: Record, AddProjectFlowPage> = { + "directory-search": "directory-search", + github: "github-search", + "new-directory": "new-directory-parent", +}; + +export function addProjectFlow(page: Page): Locator { + return page.getByTestId("add-project-flow"); +} + +export function addProjectFlowInput(page: Page): Locator { + return page.getByTestId("add-project-flow-input"); +} + +export function addProjectFlowBack(page: Page): Locator { + return page.getByTestId("add-project-flow-back"); +} + +export function addProjectFlowHost(page: Page, serverId: string): Locator { + return page.getByTestId(`add-project-flow-host-${serverId}`); +} + +export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Locator { + return page.getByTestId(`add-project-flow-method-${method}`); +} + +export async function waitForConnectedHost( + page: Page, + input: { serverId: string; endpoint: string }, +): Promise { + await page.getByTestId("sidebar-hosts-trigger").click(); + const host = page.getByTestId(`sidebar-host-row-${input.serverId}`); + await expect(host).toContainText(input.endpoint, { timeout: 30_000 }); + await page.keyboard.press("Escape"); + await expect(host).not.toBeVisible(); +} + +export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise { + const currentPage = page.getByTestId(`add-project-flow-page-${kind}`); + await expect(currentPage).toBeVisible({ timeout: 30_000 }); + return currentPage; +} + +export async function openAddProjectFlow( + page: Page, + expectedPage: "host" | "method" = "method", +): Promise { + await page.getByTestId("sidebar-add-project").click(); + await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 }); + await expectAddProjectPage(page, expectedPage); + await expect(addProjectFlowInput(page)).toBeFocused(); +} + +export async function chooseAddProjectMethod(page: Page, method: AddProjectMethod): Promise { + const option = addProjectFlowMethod(page, method); + await expect(option).toBeVisible(); + await option.click(); + if (method !== "browse") { + await expectAddProjectPage(page, METHOD_DESTINATIONS[method]); + } +} diff --git a/packages/app/e2e/helpers/isolated-host-daemon.ts b/packages/app/e2e/helpers/isolated-host-daemon.ts new file mode 100644 index 000000000..d98d40e4f --- /dev/null +++ b/packages/app/e2e/helpers/isolated-host-daemon.ts @@ -0,0 +1,129 @@ +import { once } from "node:events"; +import { spawn, execSync, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { withDisabledE2ESpeechEnv } from "./speech-env"; + +export interface IsolatedHostDaemon { + serverId: string; + port: number; + close(): Promise; +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire an isolated daemon port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForServer(port: number, child: ChildProcess): Promise { + const deadline = Date.now() + 20_000; + let lastError: unknown = null; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`); + } + try { + await new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.end(); + resolve(); + }); + socket.setTimeout(1_000, () => { + socket.destroy(); + reject(new Error(`Connection timed out to isolated daemon port ${port}`)); + }); + socket.on("error", reject); + }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + throw new Error( + `Isolated host daemon did not listen on ${port}: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + const timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, 5_000); + try { + await once(child, "exit"); + } finally { + clearTimeout(timeout); + } +} + +export async function startIsolatedHostDaemon(serverId: string): Promise { + const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0); + let port = await getAvailablePort(); + while (port === 6767 || port === primaryPort) port = await getAvailablePort(); + + const metroPort = process.env.E2E_METRO_PORT; + if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon"); + + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-")); + const serverDir = path.resolve(__dirname, "../../../server"); + const tsxBin = execSync("which tsx").toString().trim(); + const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { + cwd: serverDir, + env: withDisabledE2ESpeechEnv({ + ...process.env, + PASEO_HOME: paseoHome, + PASEO_SERVER_ID: serverId, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, + PASEO_RELAY_ENABLED: "0", + PASEO_NODE_ENV: "development", + NODE_ENV: "development", + }), + stdio: ["ignore", "ignore", "pipe"], + detached: false, + }); + + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + stderr = stderr.split("\n").slice(-40).join("\n"); + }); + + try { + await waitForServer(port, child); + } catch (error) { + await stopProcess(child); + await rm(paseoHome, { recursive: true, force: true }); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, + { cause: error }, + ); + } + + return { + serverId, + port, + close: async () => { + await stopProcess(child); + await rm(paseoHome, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 2b5faa246..f8a93b31b 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -36,6 +36,7 @@ import { } from "./helpers/github-fixtures"; import { getServerId } from "./helpers/server-id"; import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { chooseAddProjectMethod, expectAddProjectPage } from "./helpers/add-project-flow"; import { seedSavedSettingsHosts } from "./helpers/settings"; import { expectSidebarWorkspaceSelected, @@ -252,7 +253,8 @@ test.describe("New workspace flow", () => { await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/); await addProject.click(); - await expect(page.getByTestId("project-picker-input")).toBeVisible(); + await expectAddProjectPage(page, "method"); + await chooseAddProjectMethod(page, "directory-search"); } finally { await repo.cleanup(); } diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/app/e2e/project-picker-desktop.spec.ts index a8e6bf4ce..1339f5a6d 100644 --- a/packages/app/e2e/project-picker-desktop.spec.ts +++ b/packages/app/e2e/project-picker-desktop.spec.ts @@ -18,7 +18,7 @@ test("Browse opens the folder selected by the desktop dialog", async ({ await gotoAppShell(page); await page.getByTestId("sidebar-add-project").click(); - const browse = page.getByRole("button", { name: "Browse…" }); + const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); @@ -26,7 +26,7 @@ test("Browse opens the folder selected by the desktop dialog", async ({ projectPickerFixture.rememberProjectId(projectId); }); -test("Browse owns Enter without opening the active typed path", async ({ +test("canceling Browse returns to the Add Project methods", async ({ page, projectPickerFixture, }) => { @@ -38,20 +38,17 @@ test("Browse owns Enter without opening the active typed path", async ({ await gotoAppShell(page); await page.getByTestId("sidebar-add-project").click(); - const input = page.getByTestId("project-picker-input"); - await expect(input).toBeVisible({ timeout: 30_000 }); - await input.fill(projectPickerFixture.projectPath); - - const browse = page.getByRole("button", { name: "Browse…" }); + const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); - await browse.press("Enter"); + await browse.click(); const dialogOptions = await waitForDirectoryDialog(page); expect(dialogOptions).toEqual({ + createDirectory: true, directory: true, multiple: false, }); - await expect(input).toBeVisible(); + await expect(browse).toBeVisible(); await expect( page .locator('[data-testid^="sidebar-project-row-"]') diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index aa1e4af6a..1669586b5 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -31,6 +31,11 @@ import { unblockPaseoConfigWrites, } from "./helpers/project-settings"; import { gotoAppShell } from "./helpers/app"; +import { + addProjectFlowInput, + chooseAddProjectMethod, + openAddProjectFlow, +} from "./helpers/add-project-flow"; import { createTempGitRepo } from "./helpers/workspace"; const updatedSetup = ["npm install", "npm run build"]; @@ -134,10 +139,10 @@ async function readProjectConfigFile(project: ProjectsSettingsProject): Promise< } async function addProjectFromSidebar(page: Page, projectPath: string): Promise { - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); - const input = page.getByTestId("project-picker-input"); - await expect(input).toBeVisible({ timeout: 30_000 }); + const input = addProjectFlowInput(page); await input.fill(projectPath); await page.keyboard.press("Enter"); diff --git a/packages/app/src/add-project-flow/model.test.ts b/packages/app/src/add-project-flow/model.test.ts new file mode 100644 index 000000000..677e00059 --- /dev/null +++ b/packages/app/src/add-project-flow/model.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + backAddProjectPage, + chooseAddProjectHost, + currentAddProjectPage, + moveAddProjectActiveIndex, + moveAddProjectSelection, + openAddProjectFlow, + openDirectorySearchPage, + openGithubLocationPage, + openNewDirectoryNamePage, + openNewDirectoryParentPage, + setAddProjectActiveIndex, + setAddProjectPageInput, + setNewDirectoryName, + type AddProjectHost, +} from "./model"; +import { + buildAddProjectMethods, + buildCloneLocationOptions, + buildManualGithubRepositoryChoices, +} from "./options"; + +const HOST: AddProjectHost = { + serverId: "host-1", + label: "Local", + canAddProject: true, + canBrowse: true, + canCloneGithubRepositories: true, + canSearchGithubRepositories: true, + canCreateDirectory: true, +}; + +describe("Add Project navigation", () => { + it("skips a single connected host without adding it to history", () => { + const state = openAddProjectFlow({ hosts: [HOST] }); + + expect(currentAddProjectPage(state)).toEqual({ + kind: "method", + hostId: "host-1", + query: "", + activeIndex: 0, + error: null, + }); + expect(backAddProjectPage(state)).toBeNull(); + }); + + it("restores page input and selection after Back", () => { + const secondHost = { ...HOST, serverId: "host-2", label: "Remote" }; + let state = openAddProjectFlow({ hosts: [HOST, secondHost] }); + state = setAddProjectPageInput(state, "rem"); + state = setAddProjectActiveIndex(state, 1); + state = chooseAddProjectHost(state, secondHost.serverId); + state = openDirectorySearchPage(state, secondHost.serverId); + + state = backAddProjectPage(state) ?? state; + state = backAddProjectPage(state) ?? state; + + expect(currentAddProjectPage(state)).toEqual({ + kind: "host", + query: "rem", + activeIndex: 1, + error: null, + }); + }); + + it("wraps keyboard selection in both directions", () => { + expect(moveAddProjectActiveIndex(2, 3, "next")).toBe(0); + expect(moveAddProjectActiveIndex(0, 3, "previous")).toBe(2); + expect(moveAddProjectSelection(0, [true, false, true], "next")).toBe(2); + }); + + it("restores a directory name after returning to and reselecting its parent", () => { + let state = openAddProjectFlow({ hosts: [HOST] }); + state = openNewDirectoryParentPage(state, HOST.serverId); + state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev"); + state = setNewDirectoryName(state, "command-center"); + state = backAddProjectPage(state) ?? state; + state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev"); + + expect(currentAddProjectPage(state)).toMatchObject({ + kind: "new-directory-name", + parentPath: "~/dev", + name: "command-center", + }); + }); + + it("restores the GitHub destination query and active parent when reopening a repository", () => { + const repository = { + id: "repo-1", + nameWithOwner: "getpaseo/paseo", + cloneUrl: "git@github.com:getpaseo/paseo.git", + description: null, + visibility: "public", + updatedAt: null, + }; + let state = openAddProjectFlow({ hosts: [HOST] }); + state = openGithubLocationPage(state, HOST.serverId, repository); + state = setAddProjectPageInput(state, "~/dev"); + state = setAddProjectActiveIndex(state, 2); + state = backAddProjectPage(state) ?? state; + state = openGithubLocationPage(state, HOST.serverId, repository); + + expect(currentAddProjectPage(state)).toMatchObject({ + kind: "github-location", + query: "~/dev", + activeIndex: 2, + }); + }); +}); + +describe("Add Project options", () => { + it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => { + expect( + buildAddProjectMethods({ + ...HOST, + canBrowse: false, + canCloneGithubRepositories: false, + canSearchGithubRepositories: false, + canCreateDirectory: false, + }), + ).toEqual([ + { + id: "directory-search", + label: "Search for directory", + description: "Find a directory on Local", + }, + { + id: "github", + label: "Clone from GitHub", + description: "Update this host to clone GitHub repositories", + disabled: true, + }, + { + id: "new-directory", + label: "New directory", + description: "Update this host to create directories", + disabled: true, + }, + ]); + }); + + it("offers manual URL and protocol-specific owner/repo clone choices", () => { + expect(buildManualGithubRepositoryChoices("git@github.com:getpaseo/paseo.git")).toEqual([ + expect.objectContaining({ + id: "manual:git@github.com:getpaseo/paseo.git", + nameWithOwner: "getpaseo/paseo", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }), + ]); + expect(buildManualGithubRepositoryChoices("getpaseo/paseo")).toEqual([ + expect.objectContaining({ cloneProtocol: "https", cloneUrl: "getpaseo/paseo" }), + expect.objectContaining({ cloneProtocol: "ssh", cloneUrl: "getpaseo/paseo" }), + ]); + expect(buildManualGithubRepositoryChoices("paseo")).toEqual([]); + }); + + it("shows final clone paths while retaining parent paths as values", () => { + expect( + buildCloneLocationOptions({ + parents: ["~/dev", "~/workspace"], + repositoryName: "paseo", + existingPaths: ["~/workspace/paseo"], + }), + ).toEqual([ + { + id: "~/dev", + path: "~/dev", + displayPath: "~/dev/paseo", + secondaryText: "Parent directory: ~/dev", + disabled: false, + }, + { + id: "~/workspace", + path: "~/workspace", + displayPath: "~/workspace/paseo", + secondaryText: "Already exists", + disabled: true, + }, + ]); + }); +}); diff --git a/packages/app/src/add-project-flow/model.ts b/packages/app/src/add-project-flow/model.ts new file mode 100644 index 000000000..16b80e9c4 --- /dev/null +++ b/packages/app/src/add-project-flow/model.ts @@ -0,0 +1,293 @@ +export interface AddProjectHost { + serverId: string; + label: string; + canAddProject: boolean; + canBrowse: boolean; + canCloneGithubRepositories: boolean; + canSearchGithubRepositories: boolean; + canCreateDirectory: boolean; +} + +export interface GithubRepositoryChoice { + id: string; + nameWithOwner: string; + cloneUrl: string; + cloneProtocol?: "https" | "ssh"; + description: string | null; + visibility: string | null; + updatedAt: string | null; +} + +interface SearchPageState { + query: string; + activeIndex: number; + error: string | null; +} + +export type AddProjectPage = + | ({ kind: "host" } & SearchPageState) + | ({ kind: "method"; hostId: string } & SearchPageState) + | ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState) + | ({ kind: "github-search"; hostId: string } & SearchPageState) + | ({ + kind: "github-location"; + hostId: string; + repository: GithubRepositoryChoice; + isSubmitting: boolean; + } & SearchPageState) + | ({ kind: "new-directory-parent"; hostId: string } & SearchPageState) + | { + kind: "new-directory-name"; + hostId: string; + parentPath: string; + name: string; + activeIndex: number; + error: string | null; + isSubmitting: boolean; + }; + +export interface AddProjectFlowState { + hosts: AddProjectHost[]; + pages: AddProjectPage[]; + newDirectoryNameDrafts: Record; + githubLocationDrafts: Record; +} + +export interface OpenAddProjectFlowInput { + hosts: AddProjectHost[]; + preferredHostId?: string; +} + +function searchPage(kind: TKind) { + return { kind, query: "", activeIndex: 0, error: null } as const; +} + +function methodPage(hostId: string): AddProjectPage { + return { ...searchPage("method"), hostId }; +} + +export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState { + const preferredHost = input.preferredHostId + ? input.hosts.find((host) => host.serverId === input.preferredHostId) + : null; + const onlyHost = input.hosts.length === 1 ? input.hosts[0] : null; + const initialHost = preferredHost ?? onlyHost; + + return { + hosts: input.hosts, + pages: initialHost ? [methodPage(initialHost.serverId)] : [searchPage("host")], + newDirectoryNameDrafts: {}, + githubLocationDrafts: {}, + }; +} + +export function applyAvailableAddProjectHosts( + state: AddProjectFlowState, + hosts: AddProjectHost[], + preferredHostId?: string, +): AddProjectFlowState { + const current = currentAddProjectPage(state); + if (state.pages.length !== 1 || current.kind !== "host") { + return { ...state, hosts }; + } + const preferredHost = preferredHostId + ? hosts.find((host) => host.serverId === preferredHostId) + : null; + const onlyHost = hosts.length === 1 ? hosts[0] : null; + const initialHost = preferredHost ?? onlyHost; + return { + ...state, + hosts, + pages: initialHost ? [methodPage(initialHost.serverId)] : state.pages, + }; +} + +export function currentAddProjectPage(state: AddProjectFlowState): AddProjectPage { + const page = state.pages[state.pages.length - 1]; + if (!page) { + throw new Error("Add Project flow must always contain a page"); + } + return page; +} + +export function updateCurrentAddProjectPage( + state: AddProjectFlowState, + update: (page: AddProjectPage) => AddProjectPage, +): AddProjectFlowState { + const index = state.pages.length - 1; + return { + ...state, + pages: state.pages.map((page, pageIndex) => (pageIndex === index ? update(page) : page)), + }; +} + +export function pushAddProjectPage( + state: AddProjectFlowState, + page: AddProjectPage, +): AddProjectFlowState { + return { ...state, pages: [...state.pages, page] }; +} + +export function backAddProjectPage(state: AddProjectFlowState): AddProjectFlowState | null { + if (state.pages.length === 1) { + return null; + } + return { ...state, pages: state.pages.slice(0, -1) }; +} + +export function chooseAddProjectHost( + state: AddProjectFlowState, + hostId: string, +): AddProjectFlowState { + return pushAddProjectPage(state, methodPage(hostId)); +} + +export function openDirectorySearchPage( + state: AddProjectFlowState, + hostId: string, +): AddProjectFlowState { + return pushAddProjectPage(state, { + ...searchPage("directory-search"), + hostId, + isSubmitting: false, + }); +} + +export function openGithubSearchPage( + state: AddProjectFlowState, + hostId: string, +): AddProjectFlowState { + return pushAddProjectPage(state, { ...searchPage("github-search"), hostId }); +} + +export function openGithubLocationPage( + state: AddProjectFlowState, + hostId: string, + repository: GithubRepositoryChoice, +): AddProjectFlowState { + const draft = state.githubLocationDrafts[githubLocationDraftKey(hostId, repository.id)]; + return pushAddProjectPage(state, { + kind: "github-location", + query: draft?.query ?? "", + activeIndex: draft?.activeIndex ?? 0, + error: null, + hostId, + repository, + isSubmitting: false, + }); +} + +export function openNewDirectoryParentPage( + state: AddProjectFlowState, + hostId: string, +): AddProjectFlowState { + return pushAddProjectPage(state, { ...searchPage("new-directory-parent"), hostId }); +} + +export function openNewDirectoryNamePage( + state: AddProjectFlowState, + hostId: string, + parentPath: string, +): AddProjectFlowState { + const draftKey = newDirectoryDraftKey(hostId, parentPath); + return pushAddProjectPage(state, { + kind: "new-directory-name", + hostId, + parentPath, + name: state.newDirectoryNameDrafts[draftKey] ?? "", + activeIndex: 0, + error: null, + isSubmitting: false, + }); +} + +export function setAddProjectPageInput( + state: AddProjectFlowState, + value: string, +): AddProjectFlowState { + const page = currentAddProjectPage(state); + const updated = updateCurrentAddProjectPage(state, (current) => { + if (current.kind === "new-directory-name") { + return { ...current, name: value, activeIndex: 0, error: null }; + } + return { ...current, query: value, activeIndex: 0, error: null }; + }); + if (page.kind !== "github-location") return updated; + const draftKey = githubLocationDraftKey(page.hostId, page.repository.id); + return { + ...updated, + githubLocationDrafts: { + ...updated.githubLocationDrafts, + [draftKey]: { query: value, activeIndex: 0 }, + }, + }; +} + +export function setNewDirectoryName( + state: AddProjectFlowState, + value: string, +): AddProjectFlowState { + const page = currentAddProjectPage(state); + if (page.kind !== "new-directory-name") return state; + const draftKey = newDirectoryDraftKey(page.hostId, page.parentPath); + const updated = setAddProjectPageInput(state, value); + return { + ...updated, + newDirectoryNameDrafts: { + ...updated.newDirectoryNameDrafts, + [draftKey]: value, + }, + }; +} + +function newDirectoryDraftKey(hostId: string, parentPath: string): string { + return `${hostId}\u0000${parentPath}`; +} + +function githubLocationDraftKey(hostId: string, repositoryId: string): string { + return `${hostId}\u0000${repositoryId}`; +} + +export function setAddProjectActiveIndex( + state: AddProjectFlowState, + activeIndex: number, +): AddProjectFlowState { + const page = currentAddProjectPage(state); + const updated = updateCurrentAddProjectPage(state, (current) => ({ ...current, activeIndex })); + if (page.kind !== "github-location") return updated; + const draftKey = githubLocationDraftKey(page.hostId, page.repository.id); + return { + ...updated, + githubLocationDrafts: { + ...updated.githubLocationDrafts, + [draftKey]: { query: page.query, activeIndex }, + }, + }; +} + +export function moveAddProjectActiveIndex( + activeIndex: number, + optionCount: number, + direction: "next" | "previous", +): number { + if (optionCount === 0) return 0; + const delta = direction === "next" ? 1 : -1; + const next = activeIndex + delta; + if (next < 0) return optionCount - 1; + if (next >= optionCount) return 0; + return next; +} + +export function moveAddProjectSelection( + activeIndex: number, + selectable: readonly boolean[], + direction: "next" | "previous", +): number { + if (!selectable.some(Boolean)) return 0; + let next = activeIndex; + for (let count = 0; count < selectable.length; count += 1) { + next = moveAddProjectActiveIndex(next, selectable.length, direction); + if (selectable[next]) return next; + } + return activeIndex; +} diff --git a/packages/app/src/add-project-flow/options.ts b/packages/app/src/add-project-flow/options.ts new file mode 100644 index 000000000..fdaf30cfd --- /dev/null +++ b/packages/app/src/add-project-flow/options.ts @@ -0,0 +1,164 @@ +import { + isCompleteGitRemote, + parseGitHubRemoteUrl, + parseGitRemoteLocation, +} from "@getpaseo/protocol/git-remote"; +import type { AddProjectHost, GithubRepositoryChoice } from "./model"; + +export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory"; + +export interface AddProjectMethodOption { + id: AddProjectMethodId; + label: string; + description: string; + disabled?: boolean; +} + +export interface AddProjectPathOption { + id: string; + path: string; + displayPath: string; + secondaryText: string | null; + disabled: boolean; +} + +export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): AddProjectHost[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return hosts; + return hosts.filter( + (host) => + host.label.toLowerCase().includes(normalized) || + host.serverId.toLowerCase().includes(normalized), + ); +} + +export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] { + const options: AddProjectMethodOption[] = []; + if (host.canAddProject) { + options.push({ + id: "directory-search", + label: "Search for directory", + description: `Find a directory on ${host.label}`, + }); + } + if (host.canBrowse) { + options.push({ + id: "browse", + label: "Browse", + description: "Choose or create a directory in Finder", + }); + } + options.push({ + id: "github", + label: "Clone from GitHub", + description: githubMethodDescription(host), + disabled: !host.canCloneGithubRepositories, + }); + options.push({ + id: "new-directory", + label: "New directory", + description: host.canCreateDirectory + ? `Create an empty directory on ${host.label}` + : "Update this host to create directories", + disabled: !host.canCreateDirectory, + }); + return options; +} + +function githubMethodDescription(host: AddProjectHost): string { + if (!host.canCloneGithubRepositories) { + return "Update this host to clone GitHub repositories"; + } + if (host.canSearchGithubRepositories) { + return "Search projects available to your GitHub account"; + } + return "Enter a GitHub URL or owner/repo"; +} + +export function pathBaseName(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] ?? trimmed; +} + +export function buildManualGithubRepositoryChoices(query: string): GithubRepositoryChoice[] { + const repo = query.trim(); + if (!repo) return []; + + if (isCompleteGitRemote(repo)) { + const identity = parseGitHubRemoteUrl(repo); + const location = parseGitRemoteLocation(repo); + const remoteName = location ? pathBaseName(location.path).replace(/\.git$/u, "") : repo; + return [ + { + id: `manual:${repo}`, + nameWithOwner: identity?.repo ?? remoteName, + cloneUrl: repo, + description: "Clone this repository URL", + visibility: null, + updatedAt: null, + }, + ]; + } + + const shorthand = repo.match(/^([^\s/]+)\/([^\s/]+)$/u); + if (!shorthand) return []; + const nameWithOwner = `${shorthand[1]}/${shorthand[2]}`; + return (["https", "ssh"] as const).map((cloneProtocol) => ({ + id: `manual:${cloneProtocol}:${nameWithOwner}`, + nameWithOwner, + cloneUrl: nameWithOwner, + cloneProtocol, + description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`, + visibility: null, + updatedAt: null, + })); +} + +export function parentDirectory(path: string): string | null { + const trimmed = path.replace(/[\\/]+$/, ""); + const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (index < 0) return null; + if (index === 0) return trimmed.slice(0, 1); + return trimmed.slice(0, index); +} + +export function joinDirectoryPath(parent: string, name: string): string { + const trimmedParent = parent.replace(/[\\/]+$/, ""); + const separator = trimmedParent.includes("\\") && !trimmedParent.includes("/") ? "\\" : "/"; + return `${trimmedParent}${separator}${name}`; +} + +export function buildSuggestedParentDirectories(projectPaths: string[]): string[] { + const values = [ + ...projectPaths.flatMap((path) => { + const parent = parentDirectory(path); + return parent ? [parent] : []; + }), + "~/dev", + "~/Developer", + "~/src", + "~/projects", + "~/workspace", + "~", + ]; + return [...new Set(values)]; +} + +export function buildCloneLocationOptions(input: { + parents: string[]; + repositoryName: string; + existingPaths: string[]; +}): AddProjectPathOption[] { + const existing = new Set(input.existingPaths); + return input.parents.map((parent) => { + const path = joinDirectoryPath(parent, input.repositoryName); + return { + id: parent, + path: parent, + displayPath: path, + secondaryText: existing.has(path) ? "Already exists" : `Parent directory: ${parent}`, + disabled: existing.has(path), + }; + }); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 812231b9a..58abf2c05 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -31,7 +31,6 @@ import { LeftSidebar } from "@/components/left-sidebar"; import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; -import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { RootErrorBoundary } from "@/components/root-error-boundary"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; @@ -550,7 +549,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon - diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx new file mode 100644 index 000000000..6fbbd719c --- /dev/null +++ b/packages/app/src/components/add-project-flow.tsx @@ -0,0 +1,1023 @@ +import { router } from "expo-router"; +import { + ArrowLeft, + Folder, + FolderOpen, + FolderPlus, + Github, + HardDrive, + Plus, + Search, + Server, +} from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; +import { + Modal, + Pressable, + ScrollView, + Text, + TextInput, + View, + type PressableStateCallbackType, +} from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + applyAvailableAddProjectHosts, + backAddProjectPage, + chooseAddProjectHost, + currentAddProjectPage, + moveAddProjectSelection, + openAddProjectFlow, + openDirectorySearchPage, + openGithubLocationPage, + openGithubSearchPage, + openNewDirectoryNamePage, + openNewDirectoryParentPage, + setAddProjectActiveIndex, + setAddProjectPageInput, + setNewDirectoryName, + updateCurrentAddProjectPage, + type AddProjectFlowState, + type AddProjectHost, + type AddProjectPage, + type GithubRepositoryChoice, +} from "@/add-project-flow/model"; +import { + buildAddProjectMethods, + buildCloneLocationOptions, + buildManualGithubRepositoryChoices, + buildSuggestedParentDirectories, + filterAddProjectHosts, + joinDirectoryPath, + pathBaseName, + type AddProjectMethodId, +} from "@/add-project-flow/options"; +import { + buildProjectPickerOptions, + type ProjectPickerOption, +} from "@/components/project-picker-options"; +import { Shortcut } from "@/components/ui/shortcut"; +import { getIsElectronRuntime } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { pickDirectory } from "@/desktop/pick-directory"; +import { useFetchQuery } from "@/data/query"; +import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; +import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project"; +import { + useHosts, + useHostRuntimeClient, + useHostRuntimeConnectionStatuses, +} from "@/runtime/host-runtime"; +import { useHostFeatureMap } from "@/runtime/host-features"; +import { useSessionStore } from "@/stores/session-store"; +import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; +import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; +import type { Theme } from "@/styles/theme"; +import { shortenPath } from "@/utils/shorten-path"; +import { buildSettingsAddHostRoute } from "@/utils/host-routes"; + +interface AddProjectFlowProps { + request: AddProjectFlowRequest; + onClose: () => void; +} + +interface FlowRowOption { + id: string; + title: string; + subtitle: string | null; + icon: ComponentType<{ size?: number; color?: string }>; + disabled?: boolean; + testID: string; + select: () => void; +} + +type GithubLocationPage = Extract; + +interface FlowIconProps { + icon: ComponentType<{ size?: number; color?: string }>; + size?: number; + color?: string; +} + +function FlowIcon({ icon: Icon, size, color }: FlowIconProps) { + return ; +} + +const MutedFlowIcon = withUnistyles(FlowIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); +const ThemedArrowLeft = withUnistyles(ArrowLeft); +const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +const lastCloneParentByHost = new Map(); +const EMPTY_PATHS: string[] = []; +const NAVIGATION_HINT_KEYS = ["Up", "Down"]; +const SELECT_HINT_KEYS = ["Enter"]; +const ESCAPE_HINT_KEYS = ["Esc"]; + +function FlowBackButton({ onPress }: { onPress: () => void }) { + return ( + + {({ hovered, pressed }) => ( + + )} + + ); +} + +function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] { + if (method === "github") return Github; + if (method === "browse") return FolderOpen; + if (method === "new-directory") return FolderPlus; + return Search; +} + +function directoryOptionSubtitle(option: ProjectPickerOption, shortPath: string): string | null { + if (option.kind === "path") return "Open this path"; + if (shortPath === option.path) return null; + return option.path; +} + +function progressText(page: AddProjectPage): string { + if (page.kind === "github-location") return "Cloning project..."; + if (page.kind === "new-directory-name") return "Creating directory..."; + return "Adding project..."; +} + +function emptyText(page: AddProjectPage): string { + if (page.kind === "host") return "No connected hosts"; + if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo"; + return "No matching options"; +} + +interface QueryErrorInput { + searchesDirectories: boolean; + directoryFailed: boolean; + githubFailed: boolean; + githubAvailable: boolean | null; + githubError: string | null; +} + +function queryErrorText(input: QueryErrorInput): string | null { + if (input.searchesDirectories && input.directoryFailed) return "Unable to search directories"; + if (input.githubFailed) return "Unable to search GitHub repositories"; + if (input.githubError) return input.githubError; + if (input.githubAvailable === false) return input.githubError ?? "GitHub search is unavailable"; + return null; +} + +function pageHostId(page: AddProjectPage): string | null { + return page.kind === "host" ? null : page.hostId; +} + +function pageTitle(page: AddProjectPage): string { + switch (page.kind) { + case "host": + return "Choose host"; + case "method": + return "Add project"; + case "directory-search": + return "Search for directory"; + case "github-search": + return "Clone from GitHub"; + case "github-location": + return `Where should Paseo create ${pathBaseName(page.repository.nameWithOwner)}?`; + case "new-directory-parent": + return "Choose parent directory"; + case "new-directory-name": + return "Name directory"; + } +} + +function pagePlaceholder(page: AddProjectPage): string { + switch (page.kind) { + case "host": + return "Search hosts..."; + case "method": + return "Search methods..."; + case "directory-search": + return "Search directories or enter a path..."; + case "github-search": + return "Search or enter a GitHub repository..."; + case "github-location": + case "new-directory-parent": + return "Search parent directories or enter a path..."; + case "new-directory-name": + return "Directory name"; + } +} + +function pageInput(page: AddProjectPage): string { + return page.kind === "new-directory-name" ? page.name : page.query; +} + +function pathTestId(path: string): string { + return `add-project-flow-path-${encodeURIComponent(path)}`; +} + +function FlowRow({ option, active }: { option: FlowRowOption; active: boolean }) { + const accessibilityState = useMemo( + () => ({ disabled: option.disabled === true, selected: active }), + [active, option.disabled], + ); + const rowStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.row, + (active || hovered || pressed) && styles.rowActive, + option.disabled && styles.disabled, + ], + [active, option.disabled], + ); + return ( + + + + + + + {option.title} + + {option.subtitle ? ( + + {option.subtitle} + + ) : null} + + + ); +} + +function FlowHint({ keys, action }: { keys: string[]; action: string }) { + return ( + + + {action} + + ); +} + +function setPageStatus( + state: AddProjectFlowState, + kind: AddProjectPage["kind"], + input: { isSubmitting?: boolean; error?: string | null }, +): AddProjectFlowState { + return updateCurrentAddProjectPage(state, (page) => + page.kind === kind ? { ...page, ...input } : page, + ); +} + +// The product flow is intentionally one cohesive page-stack state machine. +// eslint-disable-next-line complexity +export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { + const hosts = useHosts(); + const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); + const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); + const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd"); + // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-15. + const githubCloneByHost = useHostFeatureMap(hostIds, "workspaceGithubClone"); + // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. + const githubSearchByHost = useHostFeatureMap(hostIds, "workspaceGithubRepositorySearch"); + // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. + const createDirectoryByHost = useHostFeatureMap(hostIds, "projectCreateDirectory"); + const localServerId = useLocalDaemonServerId(); + const availableHosts = useMemo( + () => + hosts.flatMap((host) => { + if (connectionStatuses.get(host.serverId) !== "online") return []; + const canAddProject = projectAddByHost.get(host.serverId) === true; + return [ + { + serverId: host.serverId, + label: host.label, + canAddProject, + canBrowse: canAddProject && getIsElectronRuntime() && localServerId === host.serverId, + canCloneGithubRepositories: githubCloneByHost.get(host.serverId) === true, + canSearchGithubRepositories: githubSearchByHost.get(host.serverId) === true, + canCreateDirectory: createDirectoryByHost.get(host.serverId) === true, + }, + ]; + }), + [ + connectionStatuses, + createDirectoryByHost, + githubCloneByHost, + githubSearchByHost, + hosts, + localServerId, + projectAddByHost, + ], + ); + const [state, setState] = useState(() => + openAddProjectFlow({ + hosts: availableHosts, + ...(request.preferredHostId ? { preferredHostId: request.preferredHostId } : {}), + }), + ); + const page = currentAddProjectPage(state); + const hostId = pageHostId(page); + const host = hostId ? state.hosts.find((candidate) => candidate.serverId === hostId) : null; + const client = useHostRuntimeClient(hostId ?? ""); + const isLocalDaemon = useIsLocalDaemon(hostId ?? ""); + const recommendedPaths = useRecommendedProjectPaths(hostId); + const openProject = useOpenProject(hostId); + const openGithubRepo = useOpenGithubRepo(hostId); + const addEmptyProject = useSessionStore((store) => store.addEmptyProject); + const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); + const inputRef = useRef(null); + const submissionInFlightRef = useRef(false); + const browseInFlightRef = useRef(false); + const query = page.kind === "new-directory-name" ? "" : page.query; + const [debouncedQuery, setDebouncedQuery] = useState(query); + + useEffect(() => { + setState((current) => + applyAvailableAddProjectHosts(current, availableHosts, request.preferredHostId), + ); + }, [availableHosts, request.preferredHostId]); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 250); + return () => clearTimeout(timer); + }, [query]); + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(timer); + }, [page.kind]); + + const searchesDirectories = + page.kind === "directory-search" || + page.kind === "github-location" || + page.kind === "new-directory-parent"; + const directoryQuery = useFetchQuery({ + queryKey: ["add-project-flow-directories", hostId, debouncedQuery], + queryFn: async () => { + if (!client) return { query: debouncedQuery, paths: [] as string[] }; + const payload = await client.getDirectorySuggestions({ + query: debouncedQuery, + includeDirectories: true, + includeFiles: false, + limit: 30, + }); + return { + query: debouncedQuery, + paths: + payload.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? + [], + }; + }, + enabled: Boolean(client && searchesDirectories), + dataShape: "value", + retry: false, + staleTimeMs: 15_000, + }); + const githubQuery = useFetchQuery({ + queryKey: ["add-project-flow-github", hostId, debouncedQuery], + queryFn: async () => { + if (!client) throw new Error("Host is unavailable"); + const payload = await client.searchGithubRepositories({ query: debouncedQuery, limit: 30 }); + return { query: debouncedQuery, payload }; + }, + enabled: Boolean(client && page.kind === "github-search" && host?.canSearchGithubRepositories), + dataShape: "value", + retry: false, + staleTimeMs: 15_000, + }); + + const handleBack = useCallback(() => { + setState((current) => { + const previous = backAddProjectPage(current); + if (previous) return previous; + onClose(); + return current; + }); + }, [onClose]); + + const openAddedProject = useCallback( + async (path: string, sourceKind: "directory-search" | "method") => { + if (submissionInFlightRef.current) return; + submissionInFlightRef.current = true; + setState((current) => + setPageStatus(current, sourceKind, { isSubmitting: true, error: null }), + ); + try { + const result = await openProject(path); + if (result.ok) { + onClose(); + return; + } + const reason = getOpenProjectFailureReason(result); + const message = + reason === "directory_not_found" ? "Directory not found" : "Unable to add project"; + setState((current) => + setPageStatus(current, sourceKind, { isSubmitting: false, error: message }), + ); + } catch { + setState((current) => + setPageStatus(current, sourceKind, { + isSubmitting: false, + error: "Unable to add project", + }), + ); + } finally { + submissionInFlightRef.current = false; + } + }, + [onClose, openProject], + ); + + const browse = useCallback(async () => { + if (!hostId || !isLocalDaemon || browseInFlightRef.current) return; + browseInFlightRef.current = true; + try { + const path = await pickDirectory(); + if (path) await openAddedProject(path, "method"); + } catch { + setState((current) => + setPageStatus(current, "method", { error: "Unable to browse for a directory" }), + ); + } finally { + browseInFlightRef.current = false; + } + }, [hostId, isLocalDaemon, openAddedProject]); + + const selectMethod = useCallback( + (method: AddProjectMethodId) => { + if (!hostId) return; + if (method === "directory-search") { + setState((current) => openDirectorySearchPage(current, hostId)); + } else if (method === "browse") { + void browse(); + } else if (method === "github") { + setState((current) => openGithubSearchPage(current, hostId)); + } else { + setState((current) => openNewDirectoryParentPage(current, hostId)); + } + }, + [browse, hostId], + ); + + const directoryPaths = useMemo( + () => (directoryQuery.data?.query === query ? directoryQuery.data.paths : EMPTY_PATHS), + [directoryQuery.data, query], + ); + const pathOptions = useMemo( + () => + buildProjectPickerOptions({ + recommendedPaths, + serverPaths: directoryPaths, + query, + }), + [directoryPaths, query, recommendedPaths], + ); + const cloneRepository = useCallback( + async (locationPage: GithubLocationPage, parentPath: string) => { + if (submissionInFlightRef.current) return; + submissionInFlightRef.current = true; + setState((current) => + setPageStatus(current, "github-location", { isSubmitting: true, error: null }), + ); + try { + const opened = await openGithubRepo( + locationPage.repository.cloneUrl, + parentPath, + locationPage.repository.cloneProtocol, + ); + if (opened) { + lastCloneParentByHost.set(locationPage.hostId, parentPath); + onClose(); + return; + } + setState((current) => + setPageStatus(current, "github-location", { + isSubmitting: false, + error: "Unable to clone repository", + }), + ); + } catch { + setState((current) => + setPageStatus(current, "github-location", { + isSubmitting: false, + error: "Unable to clone repository", + }), + ); + } finally { + submissionInFlightRef.current = false; + } + }, + [onClose, openGithubRepo], + ); + const rows = useMemo(() => { + if (page.kind === "host") { + const choices = filterAddProjectHosts(state.hosts, page.query).map( + (choice) => ({ + id: choice.serverId, + title: choice.label, + subtitle: choice.serverId, + icon: Server, + testID: `add-project-flow-host-${choice.serverId}`, + select: () => setState((current) => chooseAddProjectHost(current, choice.serverId)), + }), + ); + if (state.hosts.length === 0) { + choices.push({ + id: "add-host", + title: "Add host", + subtitle: "No connected hosts", + icon: Plus, + testID: "add-project-flow-add-host", + select: () => { + onClose(); + router.push(buildSettingsAddHostRoute(Date.now())); + }, + }); + } + return choices; + } + if (page.kind === "method") { + if (!host) return []; + const normalized = page.query.trim().toLowerCase(); + return buildAddProjectMethods(host) + .filter( + (method) => + !normalized || + method.label.toLowerCase().includes(normalized) || + method.description.toLowerCase().includes(normalized), + ) + .map((method) => ({ + id: method.id, + title: method.label, + subtitle: method.description, + icon: methodIcon(method.id), + disabled: method.disabled, + testID: `add-project-flow-method-${method.id}`, + select: () => selectMethod(method.id), + })); + } + if (page.kind === "directory-search") { + return pathOptions.map((option) => { + const shortPath = shortenPath(option.path); + return { + id: option.path, + title: shortPath, + subtitle: directoryOptionSubtitle(option, shortPath), + icon: Folder, + testID: pathTestId(option.path), + select: () => void openAddedProject(option.path, "directory-search"), + }; + }); + } + if (page.kind === "github-search") { + const search = githubQuery.data?.query === page.query ? githubQuery.data.payload : null; + const repositories = search?.repositories ?? []; + const normalizedQuery = page.query.trim().toLowerCase(); + const hasExactSearchResult = repositories.some( + (repository) => + repository.nameWithOwner.toLowerCase() === normalizedQuery || + repository.cloneUrl.toLowerCase() === normalizedQuery, + ); + const manualRepositories = hasExactSearchResult + ? [] + : buildManualGithubRepositoryChoices(page.query); + const repositoryChoices: GithubRepositoryChoice[] = [...manualRepositories, ...repositories]; + return repositoryChoices.map((repository) => ({ + id: repository.id, + title: repository.cloneProtocol + ? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}` + : repository.nameWithOwner, + subtitle: repository.description ?? repository.visibility, + icon: Github, + testID: `add-project-flow-repository-${repository.id}`, + select: () => + setState((current) => openGithubLocationPage(current, page.hostId, repository)), + })); + } + if (page.kind === "github-location") { + const repositoryName = pathBaseName(page.repository.nameWithOwner); + const lastParent = lastCloneParentByHost.get(page.hostId); + const parents = buildSuggestedParentDirectories(recommendedPaths); + const orderedParents = lastParent + ? [lastParent, ...parents.filter((parent) => parent !== lastParent)] + : parents; + const filteredParents = buildProjectPickerOptions({ + recommendedPaths: orderedParents, + serverPaths: directoryPaths, + query: page.query, + }).map((option) => option.path); + return buildCloneLocationOptions({ + parents: filteredParents, + repositoryName, + existingPaths: [...recommendedPaths, ...directoryPaths], + }).map((option) => ({ + id: option.id, + title: shortenPath(option.displayPath), + subtitle: option.secondaryText, + icon: HardDrive, + disabled: option.disabled, + testID: pathTestId(option.displayPath), + select: () => void cloneRepository(page, option.path), + })); + } + if (page.kind === "new-directory-parent") { + return pathOptions.map((option) => ({ + id: option.path, + title: shortenPath(option.path), + subtitle: option.kind === "path" ? "Use this parent" : option.path, + icon: Folder, + testID: pathTestId(option.path), + select: () => + setState((current) => openNewDirectoryNamePage(current, page.hostId, option.path)), + })); + } + return []; + }, [ + cloneRepository, + directoryPaths, + githubQuery.data, + host, + onClose, + openAddedProject, + page, + pathOptions, + recommendedPaths, + selectMethod, + state.hosts, + ]); + + const activeIndex = rows.length === 0 ? 0 : Math.min(page.activeIndex, rows.length - 1); + const createDirectory = useCallback(async () => { + if (page.kind !== "new-directory-name" || !client) return; + const name = page.name.trim(); + if (!name || name === "." || name === ".." || /[\\/]/.test(name)) { + setState((current) => + setPageStatus(current, "new-directory-name", { error: "Enter a directory name" }), + ); + return; + } + if (submissionInFlightRef.current) return; + submissionInFlightRef.current = true; + setState((current) => + setPageStatus(current, "new-directory-name", { isSubmitting: true, error: null }), + ); + try { + const payload = await client.createProjectDirectory({ + parentPath: page.parentPath, + name, + }); + if (payload.error || !payload.project) { + setState((current) => + setPageStatus(current, "new-directory-name", { + isSubmitting: false, + error: payload.error ?? "Unable to create directory", + }), + ); + return; + } + registerProjectDescriptor({ + serverId: page.hostId, + project: payload.project, + addEmptyProject, + setHasHydratedWorkspaces, + }); + onClose(); + } catch { + setState((current) => + setPageStatus(current, "new-directory-name", { + isSubmitting: false, + error: "Unable to create directory", + }), + ); + } finally { + submissionInFlightRef.current = false; + } + }, [addEmptyProject, client, onClose, page, setHasHydratedWorkspaces]); + + const submitActive = useCallback(() => { + if (page.kind === "new-directory-name") { + void createDirectory(); + return; + } + const option = rows[activeIndex]; + if (option && !option.disabled) option.select(); + }, [activeIndex, createDirectory, page.kind, rows]); + + const handleKey = useCallback( + (key: string): boolean => { + if (key === "Escape") { + handleBack(); + return true; + } + if (key === "Enter") { + submitActive(); + return true; + } + if (key !== "ArrowDown" && key !== "ArrowUp") return false; + const next = moveAddProjectSelection( + activeIndex, + rows.map((row) => row.disabled !== true), + key === "ArrowDown" ? "next" : "previous", + ); + setState((current) => setAddProjectActiveIndex(current, next)); + return true; + }, + [activeIndex, handleBack, rows, submitActive], + ); + + useEffect(() => { + if (!isWeb || typeof window === "undefined") return; + const listener = (event: KeyboardEvent) => { + if (handleKey(event.key)) event.preventDefault(); + }; + window.addEventListener("keydown", listener, true); + return () => window.removeEventListener("keydown", listener, true); + }, [handleKey]); + + const handleNativeKeyPress = useCallback( + ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { + if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") { + handleKey(key); + } + }, + [handleKey], + ); + + const handleInputChange = useCallback((value: string) => { + setState((current) => + currentAddProjectPage(current).kind === "new-directory-name" + ? setNewDirectoryName(current, value) + : setAddProjectPageInput(current, value), + ); + }, []); + const isSubmitting = "isSubmitting" in page && page.isSubmitting; + const currentGithubSearch = + page.kind === "github-search" && githubQuery.data?.query === page.query + ? githubQuery.data.payload + : null; + const loading = + (searchesDirectories && (query !== debouncedQuery || directoryQuery.isFetching)) || + (page.kind === "github-search" && + host?.canSearchGithubRepositories === true && + (query !== debouncedQuery || githubQuery.isFetching)); + const queryError = queryErrorText({ + searchesDirectories, + directoryFailed: directoryQuery.isError, + githubFailed: page.kind === "github-search" && githubQuery.isError, + githubAvailable: currentGithubSearch?.available ?? null, + githubError: currentGithubSearch?.error ?? null, + }); + const preview = + page.kind === "new-directory-name" && page.name.trim() + ? joinDirectoryPath(page.parentPath, page.name.trim()) + : null; + + return ( + + + + + + + {state.pages.length > 1 ? : null} + + + {pageTitle(page)} + + {host ? ( + + {host.label} + + ) : null} + + + + + + {preview ? ( + + {shortenPath(preview)} + + ) : null} + {isSubmitting ? ( + + {progressText(page)} + + ) : null} + {!isSubmitting && page.error ? ( + + {page.error} + + ) : null} + {!isSubmitting && queryError ? ( + + {queryError} + + ) : null} + {!isSubmitting && loading ? ( + + Loading... + + ) : null} + {!isSubmitting && + (!loading || page.kind === "github-search") && + (!queryError || page.kind === "github-search") + ? rows.map((option, index) => ( + + )) + : null} + {!isSubmitting && + !loading && + !queryError && + rows.length === 0 && + page.kind !== "new-directory-name" ? ( + + {emptyText(page)} + + ) : null} + + + + + 1 ? "Back" : "Close"} /> + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + justifyContent: "flex-start", + alignItems: "center", + paddingTop: theme.spacing[12], + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + panel: { + width: 640, + maxWidth: "92%", + maxHeight: "80%", + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface0, + overflow: "hidden", + ...theme.shadow.lg, + }, + header: { + flexShrink: 0, + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + gap: theme.spacing[2], + }, + titleRow: { + minHeight: theme.iconSize.lg, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + backButton: { + width: 18, + height: theme.iconSize.lg, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + titleGroup: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, + title: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + hostContext: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + input: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + paddingVertical: theme.spacing[1], + outlineStyle: "none", + } as object, + results: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, + resultsContent: { paddingVertical: theme.spacing[2] }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + }, + rowActive: { backgroundColor: theme.colors.surface1 }, + disabled: { opacity: theme.opacity[50] }, + iconSlot: { width: 18, alignItems: "center" }, + rowText: { flex: 1, minWidth: 0 }, + rowTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm }, + rowSubtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: 2 }, + preview: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + }, + stateText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + }, + errorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.xs, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + }, + footer: { + flexShrink: 0, + flexDirection: "row", + gap: theme.spacing[4], + alignItems: "center", + flexWrap: "wrap", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + footerHint: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + footerKeyText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + }, + footerAction: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, +})); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index fe609169f..a3ed82c5f 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -21,6 +21,8 @@ import { import { AgentStatusDot } from "@/components/agent-status-dot"; import { Shortcut } from "@/components/ui/shortcut"; import { isNative, isWeb } from "@/constants/platform"; +import { AddProjectFlow } from "@/components/add-project-flow"; +import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; import { useIsCompactFormFactor } from "@/constants/layout"; import { IsolatedBottomSheetModal, @@ -336,6 +338,7 @@ export function CommandCenter() { handleSelectItem, handleKeyEvent, } = useCommandCenter(); + const addProjectRequest = useAddProjectFlowStore((state) => state.request); const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; @@ -490,6 +493,16 @@ export function CommandCenter() { const snapPoints = useMemo(() => ["60%", "90%"], []); + if (open && addProjectRequest) { + return ( + + ); + } + const resultList = items.length === 0 ? ( {t("shell.commandCenter.noMatches")} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index cd5be3247..4dbac08be 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -34,7 +34,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; @@ -158,7 +158,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole } }, [isRevalidating, isManualRefresh]); - const openProjectPicker = useOpenProjectPicker(); + const openProjectPicker = useOpenAddProject(); const handleOpenProjectMobile = useCallback(() => { showMobileAgent(); diff --git a/packages/app/src/components/project-picker-browse-button.electron.tsx b/packages/app/src/components/project-picker-browse-button.electron.tsx deleted file mode 100644 index d2c4889e1..000000000 --- a/packages/app/src/components/project-picker-browse-button.electron.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { FolderOpen } from "lucide-react-native"; -import { Button } from "@/components/ui/button"; -import { pickDirectory } from "@/desktop/pick-directory"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types"; - -export function ProjectPickerBrowseButton({ - serverId, - disabled, - onSelect, - onError, -}: ProjectPickerBrowseButtonProps) { - const { t } = useTranslation(); - const isLocalDaemon = useIsLocalDaemon(serverId); - const handlePress = useCallback(() => { - void (async () => { - try { - const path = await pickDirectory(); - if (path) { - onSelect(path); - } - } catch { - onError(); - } - })(); - }, [onError, onSelect]); - - if (!isLocalDaemon) { - return null; - } - - return ( - - ); -} diff --git a/packages/app/src/components/project-picker-browse-button.tsx b/packages/app/src/components/project-picker-browse-button.tsx deleted file mode 100644 index 25c2a6a9e..000000000 --- a/packages/app/src/components/project-picker-browse-button.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types"; - -export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) { - return null; -} diff --git a/packages/app/src/components/project-picker-browse-button.types.ts b/packages/app/src/components/project-picker-browse-button.types.ts deleted file mode 100644 index 909b81c4f..000000000 --- a/packages/app/src/components/project-picker-browse-button.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface ProjectPickerBrowseButtonProps { - serverId: string; - disabled: boolean; - onSelect: (path: string) => void; - onError: () => void; -} diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx deleted file mode 100644 index 9044875ad..000000000 --- a/packages/app/src/components/project-picker-modal.tsx +++ /dev/null @@ -1,869 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type RefObject, - type SetStateAction, -} from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - TextInput, - View, - type PressableStateCallbackType, - type StyleProp, - type TextStyle, -} from "react-native"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { Folder, Github } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { - getOpenProjectFailureReason, - type OpenProjectFailureReason, - type WorkspaceGithubCloneProtocol, -} from "@/hooks/open-project"; -import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useProjectPickerStore } from "@/stores/project-picker-store"; -import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; -import { shortenPath } from "@/utils/shorten-path"; -import { isNative } from "@/constants/platform"; -import { ProjectPickerBrowseButton } from "./project-picker-browse-button"; -import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; -import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options"; - -type ProjectPickerMode = "local" | "github"; - -const DEFAULT_CLONE_TARGET_DIRECTORY = "~/workspace"; -const CLONE_REPO_ERROR_MESSAGE = "Unable to clone that GitHub repository."; -const CLONE_PROTOCOL_ERROR_MESSAGE = "Choose HTTPS or SSH for owner/repo repository names."; - -interface PathRowProps { - option: ProjectPickerOption; - active: boolean; - onSelect: (path: string) => void; -} - -function PathRow({ option, active, onSelect }: PathRowProps) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const path = option.path; - const handlePress = useCallback(() => { - onSelect(path); - }, [onSelect, path]); - const pressableStyle = useCallback( - ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.row, - (Boolean(hovered) || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ], - [active, theme.colors.surface1], - ); - const rowTextStyle = useMemo( - () => [styles.rowText, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const rowActionTextStyle = useMemo( - () => [styles.rowActionText, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - return ( - - - - - - - {shortenPath(path)} - - {option.kind === "path" ? ( - {t("projectPicker.openPath")} - ) : null} - - - ); -} - -interface ProjectPickerResultsProps { - options: ProjectPickerOption[]; - activeIndex: number; - isSubmitting: boolean; - openErrorMessage: string | null; - hasQuery: boolean; - isSearching: boolean; - emptyTextStyle: StyleProp; - errorTextStyle: StyleProp; - onSelect: (path: string) => void; -} - -function ProjectPickerResults({ - options, - activeIndex, - isSubmitting, - openErrorMessage, - hasQuery, - isSearching, - emptyTextStyle, - errorTextStyle, - onSelect, -}: ProjectPickerResultsProps) { - const { t } = useTranslation(); - const canShowResultState = !isSubmitting && !openErrorMessage; - - return ( - - {isSubmitting ? {t("projectPicker.opening")} : null} - {!isSubmitting && openErrorMessage ? ( - {openErrorMessage} - ) : null} - {canShowResultState && options.length === 0 && !hasQuery ? ( - {t("projectPicker.empty")} - ) : null} - {canShowResultState && isSearching ? ( - {t("projectPicker.searching")} - ) : null} - {canShowResultState && !isSearching && options.length === 0 && hasQuery ? ( - {t("common.empty.noOptionsMatchSearch")} - ) : null} - {canShowResultState && options.length > 0 ? ( - <> - {options.map((option, index) => ( - - ))} - - ) : null} - - ); -} - -interface CloneProtocolButtonProps { - label: string; - active: boolean; - onPress: () => void; -} - -function CloneProtocolButton({ label, active, onPress }: CloneProtocolButtonProps) { - const { theme } = useUnistyles(); - const buttonStyle = useMemo( - () => [ - styles.protocolButton, - { - borderColor: theme.colors.border, - backgroundColor: active ? theme.colors.surface1 : "transparent", - }, - ], - [active, theme.colors.border, theme.colors.surface1], - ); - const textStyle = useMemo( - () => [ - styles.protocolButtonText, - { color: active ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [active, theme.colors.foreground, theme.colors.foregroundMuted], - ); - return ( - - {label} - - ); -} - -interface GithubRepoFormProps { - repo: string; - cloneProtocol: WorkspaceGithubCloneProtocol | null; - targetDirectory: string; - needsCloneProtocol: boolean; - isSubmitting: boolean; - repoInputRef: RefObject; - onChangeRepo: (text: string) => void; - onChangeTargetDirectory: (text: string) => void; - onSelectHttps: () => void; - onSelectSsh: () => void; - onSubmit: () => void; -} - -function GithubRepoForm({ - repo, - cloneProtocol, - targetDirectory, - needsCloneProtocol, - isSubmitting, - repoInputRef, - onChangeRepo, - onChangeTargetDirectory, - onSelectHttps, - onSelectSsh, - onSubmit, -}: GithubRepoFormProps) { - const { theme } = useUnistyles(); - const inputStyle = useMemo( - () => [styles.input, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const labelStyle = useMemo( - () => [styles.label, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - - return ( - - - GitHub repo - - - {needsCloneProtocol ? ( - - Clone protocol - - - - - - ) : null} - - Checkout directory - - - - ); -} - -interface ProjectPickerKeyboardNavigationInput { - open: boolean; - mode: ProjectPickerMode; - optionsLength: number; - onClose: () => void; - setActiveIndex: Dispatch>; -} - -function useProjectPickerKeyboardNavigation({ - open, - mode, - optionsLength, - onClose, - setActiveIndex, -}: ProjectPickerKeyboardNavigationInput) { - useEffect(() => { - if (!open || isNative) return; - - function handler(event: KeyboardEvent) { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return; - - if (key === "Escape") { - event.preventDefault(); - onClose(); - return; - } - - if (mode !== "local" || optionsLength === 0) return; - event.preventDefault(); - setActiveIndex((current) => { - const delta = key === "ArrowDown" ? 1 : -1; - const next = current + delta; - if (next < 0) return optionsLength - 1; - if (next >= optionsLength) return 0; - return next; - }); - } - - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [mode, onClose, open, optionsLength, setActiveIndex]); -} - -interface GithubCloneModeInput { - client: ReturnType; - serverId: string | null; - openGithubRepo: ReturnType; - close: () => void; -} - -function useGithubCloneMode({ client, serverId, openGithubRepo, close }: GithubCloneModeInput) { - const [repo, setRepo] = useState(""); - const [cloneProtocol, setCloneProtocol] = useState(null); - const [targetDirectory, setTargetDirectory] = useState(DEFAULT_CLONE_TARGET_DIRECTORY); - const [cloneErrorText, setCloneErrorText] = useState(null); - const needsCloneProtocol = repo.trim().length > 0 && !isCompleteGitRemote(repo); - - const reset = useCallback(() => { - setRepo(""); - setCloneProtocol(null); - setTargetDirectory(DEFAULT_CLONE_TARGET_DIRECTORY); - setCloneErrorText(null); - }, []); - - const handleChangeRepo = useCallback((text: string) => { - setRepo(text); - if (isCompleteGitRemote(text)) { - setCloneProtocol(null); - } - setCloneErrorText(null); - }, []); - - const handleChangeTargetDirectory = useCallback((text: string) => { - setTargetDirectory(text); - setCloneErrorText(null); - }, []); - - const handleSetHttpsProtocol = useCallback(() => { - setCloneProtocol("https"); - setCloneErrorText(null); - }, []); - - const handleSetSshProtocol = useCallback(() => { - setCloneProtocol("ssh"); - setCloneErrorText(null); - }, []); - - const handleCloneRepo = useCallback(async () => { - const trimmedRepo = repo.trim(); - const trimmedTargetDirectory = targetDirectory.trim(); - if (!trimmedRepo || !trimmedTargetDirectory || !client || !serverId) return false; - const repoIsCompleteRemote = isCompleteGitRemote(trimmedRepo); - if (!repoIsCompleteRemote && !cloneProtocol) { - setCloneErrorText(CLONE_PROTOCOL_ERROR_MESSAGE); - return false; - } - - setCloneErrorText(null); - const didOpenProject = await openGithubRepo( - trimmedRepo, - trimmedTargetDirectory, - repoIsCompleteRemote ? undefined : (cloneProtocol ?? undefined), - ); - if (!didOpenProject) { - setCloneErrorText(CLONE_REPO_ERROR_MESSAGE); - return false; - } - close(); - return true; - }, [client, cloneProtocol, close, openGithubRepo, repo, serverId, targetDirectory]); - - return { - repo, - cloneProtocol, - targetDirectory, - cloneErrorText, - needsCloneProtocol, - reset, - handleChangeRepo, - handleChangeTargetDirectory, - handleSetHttpsProtocol, - handleSetSshProtocol, - handleCloneRepo, - }; -} - -export function ProjectPickerModal() { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const request = useProjectPickerStore((state) => state.request); - const close = useProjectPickerStore((state) => state.close); - const serverId = request?.serverId ?? null; - const open = request !== null; - - const client = useHostRuntimeClient(serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const recommendedPaths = useRecommendedProjectPaths(serverId); - - const inputRef = useRef(null); - const repoInputRef = useRef(null); - const [mode, setMode] = useState("local"); - const [query, setQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(0); - const [isSubmitting, setIsSubmitting] = useState(false); - const [openErrorReason, setOpenErrorReason] = useState(null); - const openProject = useOpenProject(serverId); - const openGithubRepo = useOpenGithubRepo(serverId); - const supportsGithubClone = - client?.getLastServerInfoMessage()?.features?.workspaceGithubClone === true; - const { - repo, - cloneProtocol, - targetDirectory, - cloneErrorText, - needsCloneProtocol, - reset: resetGithubCloneMode, - handleChangeRepo, - handleChangeTargetDirectory, - handleSetHttpsProtocol, - handleSetSshProtocol, - handleCloneRepo: cloneGithubRepo, - } = useGithubCloneMode({ client, serverId, openGithubRepo, close }); - - const directorySuggestionsQuery = useQuery({ - queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery], - queryFn: async () => { - if (!client) { - return { query: debouncedQuery, paths: [] }; - } - const result = await client.getDirectorySuggestions({ - query: debouncedQuery, - includeDirectories: true, - includeFiles: false, - limit: 30, - }); - return { - query: debouncedQuery, - paths: - result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? - [], - }; - }, - enabled: Boolean(client) && isConnected && open && mode === "local", - staleTime: 15_000, - retry: false, - }); - - const options = useMemo(() => { - const currentSuggestions = - directorySuggestionsQuery.data?.query === query ? directorySuggestionsQuery.data : null; - return buildProjectPickerOptions({ - recommendedPaths, - serverPaths: currentSuggestions?.paths ?? [], - query, - }); - }, [directorySuggestionsQuery.data, query, recommendedPaths]); - const hasQuery = query.trim().length > 0; - const isSearching = - hasQuery && - options.length === 0 && - (query !== debouncedQuery || directorySuggestionsQuery.isFetching); - - const openErrorMessage = useMemo(() => { - if (!openErrorReason) { - return null; - } - - return t(`projectPicker.errors.${openErrorReason}`); - }, [openErrorReason, t]); - - const handleClose = useCallback(() => { - close(); - }, [close]); - - const handleSelectPath = useCallback( - async (path: string) => { - const trimmed = path.trim(); - if (!trimmed || !client || !serverId) return; - - setOpenErrorReason(null); - setIsSubmitting(true); - try { - const result = await openProject(trimmed); - if (result.ok) { - close(); - return; - } - - setOpenErrorReason(getOpenProjectFailureReason(result)); - } catch { - setOpenErrorReason("open_failed"); - } finally { - setIsSubmitting(false); - } - }, - [client, close, openProject, serverId], - ); - - const handleCloneRepo = useCallback(async () => { - setIsSubmitting(true); - try { - await cloneGithubRepo(); - } finally { - setIsSubmitting(false); - } - }, [cloneGithubRepo]); - - const submitActiveOption = useCallback(() => { - if (mode === "github") { - void handleCloneRepo(); - return; - } - const option = options[activeIndex]; - if (!option) return; - void handleSelectPath(option.path); - }, [activeIndex, handleCloneRepo, handleSelectPath, mode, options]); - - const handleChangeQuery = useCallback((text: string) => { - setQuery(text); - setActiveIndex(0); - setOpenErrorReason(null); - }, []); - - const handleBrowseError = useCallback(() => { - setOpenErrorReason("open_failed"); - }, []); - - const handleSetLocalMode = useCallback(() => { - setMode("local"); - setActiveIndex(0); - }, []); - - const handleSetGithubMode = useCallback(() => { - setMode("github"); - setActiveIndex(0); - setOpenErrorReason(null); - }, []); - - useEffect(() => { - if (open) { - setMode("local"); - setQuery(""); - setDebouncedQuery(""); - resetGithubCloneMode(); - setActiveIndex(0); - setOpenErrorReason(null); - const id = setTimeout(() => inputRef.current?.focus(), 0); - return () => clearTimeout(id); - } - }, [open, resetGithubCloneMode]); - - useEffect(() => { - if (!open) return; - const id = setTimeout(() => { - if (mode === "github") { - repoInputRef.current?.focus(); - return; - } - inputRef.current?.focus(); - }, 0); - return () => clearTimeout(id); - }, [mode, open]); - - // Debounce the query that drives the (potentially multi-second) directory - // suggestions RPC so fast typing doesn't fire a filesystem scan per keystroke. - useEffect(() => { - const id = setTimeout(() => setDebouncedQuery(query), 250); - return () => clearTimeout(id); - }, [query]); - - useEffect(() => { - if (!open || mode !== "local") return; - if (activeIndex >= options.length) { - setActiveIndex(options.length > 0 ? options.length - 1 : 0); - } - }, [activeIndex, mode, options.length, open]); - - useProjectPickerKeyboardNavigation({ - open, - mode, - optionsLength: options.length, - onClose: close, - setActiveIndex, - }); - - const panelStyle = useMemo( - () => [ - styles.panel, - { - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, - }, - ], - [theme.colors.border, theme.colors.surface0], - ); - const headerStyle = useMemo( - () => [styles.header, { borderBottomColor: theme.colors.border }], - [theme.colors.border], - ); - const inputStyle = useMemo( - () => [styles.input, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const emptyTextStyle = useMemo( - () => [styles.emptyText, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); - const errorTextStyle = useMemo( - () => [styles.emptyText, { color: theme.colors.destructive }], - [theme.colors.destructive], - ); - const cloneErrorTextStyle = useMemo( - () => [styles.errorText, { color: theme.colors.destructive }], - [theme.colors.destructive], - ); - const modeButtonStyle = useCallback( - (buttonMode: ProjectPickerMode) => [ - styles.modeButton, - { - borderColor: theme.colors.border, - backgroundColor: mode === buttonMode ? theme.colors.surface1 : "transparent", - }, - ], - [mode, theme.colors.border, theme.colors.surface1], - ); - const modeButtonTextStyle = useCallback( - (buttonMode: ProjectPickerMode) => [ - styles.modeButtonText, - { color: mode === buttonMode ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [mode, theme.colors.foreground, theme.colors.foregroundMuted], - ); - - if (!serverId) return null; - - return ( - - - - - - - - - - Local folder - - {supportsGithubClone ? ( - - - GitHub repo - - ) : null} - - - {mode === "local" ? ( - - - - - ) : ( - - )} - - - {mode === "local" ? ( - - ) : ( - - {cloneErrorText ? {cloneErrorText} : null} - {isSubmitting ? Cloning repository... : null} - - )} - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - overlay: { - flex: 1, - justifyContent: "flex-start", - alignItems: "center", - paddingTop: theme.spacing[12], - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0, 0, 0, 0.5)", - }, - panel: { - width: 640, - maxWidth: "92%", - maxHeight: "80%", - borderWidth: 1, - borderRadius: theme.borderRadius.lg, - overflow: "hidden", - ...theme.shadow.lg, - }, - header: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderBottomWidth: 1, - gap: theme.spacing[3], - }, - localInputRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - modeRow: { - flexDirection: "row", - gap: theme.spacing[2], - }, - modeButton: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - }, - modeButtonText: { - fontSize: theme.fontSize.sm, - fontWeight: "600", - }, - githubForm: { - gap: theme.spacing[3], - }, - fieldGroup: { - gap: theme.spacing[1], - }, - label: { - fontSize: theme.fontSize.xs, - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0, - }, - protocolRow: { - flexDirection: "row", - gap: theme.spacing[2], - }, - protocolButton: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - }, - protocolButtonText: { - fontSize: theme.fontSize.sm, - fontWeight: "600", - }, - input: { - flex: 1, - fontSize: theme.fontSize.lg, - paddingVertical: theme.spacing[1], - outlineStyle: "none", - } as object, - results: { - flexGrow: 0, - }, - resultsContent: { - paddingVertical: theme.spacing[2], - }, - row: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[2], - }, - rowContent: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - iconSlot: { - width: 16, - height: 20, - alignItems: "center", - justifyContent: "center", - }, - rowText: { - fontSize: theme.fontSize.base, - fontWeight: "400", - lineHeight: 20, - flex: 1, - flexShrink: 1, - }, - rowActionText: { - fontSize: theme.fontSize.xs, - fontWeight: "500", - }, - emptyText: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[4], - fontSize: theme.fontSize.base, - }, - errorText: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[3], - paddingBottom: theme.spacing[1], - fontSize: theme.fontSize.sm, - fontWeight: "600", - }, -})); diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 88172579c..41f872c12 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -24,6 +24,7 @@ export interface DesktopDialogOpenOptions { title?: string; defaultPath?: string; directory?: boolean; + createDirectory?: boolean; multiple?: boolean; filters?: Array<{ name: string; diff --git a/packages/app/src/desktop/pick-directory.test.ts b/packages/app/src/desktop/pick-directory.test.ts index e95582fc1..f7a956b69 100644 --- a/packages/app/src/desktop/pick-directory.test.ts +++ b/packages/app/src/desktop/pick-directory.test.ts @@ -19,6 +19,7 @@ describe("pickDirectory", () => { { directory: true, multiple: false, + createDirectory: true, }, ]); }); diff --git a/packages/app/src/desktop/pick-directory.ts b/packages/app/src/desktop/pick-directory.ts index 3dd46db9f..0e0ab144f 100644 --- a/packages/app/src/desktop/pick-directory.ts +++ b/packages/app/src/desktop/pick-directory.ts @@ -11,6 +11,7 @@ export async function pickDirectory( const selection = await open({ directory: true, multiple: false, + createDirectory: true, }); if (selection === null) { return null; diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index d3b4e5406..a86f51e68 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -1,5 +1,9 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ProjectAddResponse, WorkspaceGithubCloneProtocol } from "@getpaseo/protocol/messages"; +import type { + ProjectAddResponse, + WorkspaceGithubCloneProtocol, + WorkspaceProjectDescriptorPayload, +} from "@getpaseo/protocol/messages"; import { normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, normalizeWorkspaceDescriptor, @@ -62,6 +66,21 @@ interface WorkspaceOpenCallbacks { navigateToWorkspace: (input: NavigateToWorkspaceInput) => string; } +export interface RegisterProjectDescriptorInput { + serverId: string; + project: WorkspaceProjectDescriptorPayload; + addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; +} + +export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean { + const serverId = input.serverId.trim(); + if (!serverId) return false; + input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project)); + input.setHasHydratedWorkspaces(serverId, true); + return true; +} + export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks { repo: string; targetDirectory: string; @@ -95,11 +114,12 @@ export async function openProjectDirectly( }; } - input.addEmptyProject( - normalizedServerId, - normalizeProjectWithoutWorkspacesDescriptor(payload.project), - ); - input.setHasHydratedWorkspaces(normalizedServerId, true); + registerProjectDescriptor({ + serverId: normalizedServerId, + project: payload.project, + addEmptyProject: input.addEmptyProject, + setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, + }); return { ok: true }; } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 414fb4c94..327f3ac8f 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, @@ -59,7 +59,7 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { interface CommandCenterActionDefinition { id: string; titleKey: - | "shell.commandCenter.openProject" + | "shell.commandCenter.addProject" | "shell.commandCenter.home" | "sidebar.actions.settings"; icon?: "plus" | "settings" | "home"; @@ -71,7 +71,7 @@ interface CommandCenterActionDefinition { const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ { id: "new-agent", - titleKey: "shell.commandCenter.openProject", + titleKey: "shell.commandCenter.addProject", icon: "plus", actionId: "new-agent", keywords: ["open", "project", "folder", "workspace", "repo"], @@ -146,6 +146,9 @@ export function useCommandCenter() { const { overrides } = useKeyboardShortcutOverrides(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); + const addProjectRequest = useAddProjectFlowStore((state) => state.request); + const openAddProjectFlow = useAddProjectFlowStore((state) => state.open); + const closeAddProjectFlow = useAddProjectFlowStore((state) => state.close); const inputRef = useRef(null); const didNavigateRef = useRef(false); const prevOpenRef = useRef(open); @@ -318,23 +321,21 @@ export function useCommandCenter() { [setOpen], ); - const openProjectPicker = useOpenProjectPicker(); - const handleSelectAction = useCallback( (action: CommandCenterActionItem) => { - clearCommandCenterFocusRestoreElement(); - setOpen(false); if (action.id === "new-agent") { - void openProjectPicker(); + openAddProjectFlow(); return; } + clearCommandCenterFocusRestoreElement(); + setOpen(false); if (!action.route) { return; } didNavigateRef.current = true; router.push(action.route); }, - [openProjectPicker, setOpen], + [openAddProjectFlow, setOpen], ); const handleSelectItem = useCallback( @@ -373,6 +374,7 @@ export function useCommandCenter() { prevOpenRef.current = open; if (!open) { + closeAddProjectFlow(); setQuery(""); setActiveIndex(0); @@ -403,7 +405,7 @@ export function useCommandCenter() { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); - }, [open]); + }, [closeAddProjectFlow, open]); useEffect(() => { if (!open) return; @@ -414,7 +416,7 @@ export function useCommandCenter() { const handleKeyEvent = useCallback( (key: string): boolean => { - if (!open) return false; + if (!open || addProjectRequest) return false; const currentItems = itemsRef.current; if (key === "Escape") { @@ -443,11 +445,11 @@ export function useCommandCenter() { return false; }, - [open], + [addProjectRequest, open], ); useEffect(() => { - if (!open || !isWeb) return; + if (!open || addProjectRequest || !isWeb) return; const handler = (event: KeyboardEvent) => { if ( @@ -466,7 +468,7 @@ export function useCommandCenter() { // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [open, handleKeyEvent]); + }, [addProjectRequest, open, handleKeyEvent]); return { open, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 67c81bc54..78b371a61 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -18,7 +18,7 @@ import { type ShortcutCallbackName, } from "@/keyboard/route-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; -import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; @@ -54,7 +54,7 @@ export function useKeyboardShortcuts({ step: 0, timeoutId: null, }); - const openProjectPickerAction = useOpenProjectPicker(); + const openProjectPickerAction = useOpenAddProject(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const keyboardWorkspaceSelectionRef = useRef(null); diff --git a/packages/app/src/hooks/use-open-add-project.ts b/packages/app/src/hooks/use-open-add-project.ts new file mode 100644 index 000000000..5c6bebafa --- /dev/null +++ b/packages/app/src/hooks/use-open-add-project.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; +import { useAddProjectFlowStore } from "@/stores/add-project-flow-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; + +export function useOpenAddProject(): (preferredHostId?: string) => void { + const openFlow = useAddProjectFlowStore((state) => state.open); + const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + + return useCallback( + (preferredHostId?: string) => { + openFlow(preferredHostId); + setCommandCenterOpen(true); + }, + [openFlow, setCommandCenterOpen], + ); +} diff --git a/packages/app/src/hooks/use-open-project-picker.ts b/packages/app/src/hooks/use-open-project-picker.ts deleted file mode 100644 index 46f3fe387..000000000 --- a/packages/app/src/hooks/use-open-project-picker.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback } from "react"; -import { useHostChooser } from "@/hosts/host-chooser"; -import { useProjectPickerStore } from "@/stores/project-picker-store"; - -export function useOpenProjectPicker(): () => void { - const chooseHost = useHostChooser(); - const openProjectPicker = useProjectPickerStore((state) => state.open); - - return useCallback(() => { - chooseHost({ - title: "Choose host", - onChooseHost: openProjectPicker, - }); - }, [chooseHost, openProjectPicker]); -} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index dbfa68d85..7a9af4f6c 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -182,7 +182,7 @@ describe("translation resources", () => { expect(en.shell.commandCenter.workspaces).toBe("Workspaces"); expect(en.shell.commandCenter.agents).toBe("Agents"); expect(en.shell.commandCenter.newAgent).toBe("New agent"); - expect(en.shell.commandCenter.openProject).toBe("Open project"); + expect(en.shell.commandCenter.addProject).toBe("Add project"); expect(en.shell.commandCenter.home).toBe("Home"); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 99b6f3566..2fa57725a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -60,7 +60,7 @@ export const ar: TranslationResources = { workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", - openProject: "مشروع مفتوح", + addProject: "إضافة مشروع", home: "بيت", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3da4cff63..c85d45fd6 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -58,7 +58,7 @@ export const en = { workspaces: "Workspaces", agents: "Agents", newAgent: "New agent", - openProject: "Open project", + addProject: "Add project", home: "Home", }, }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 98bae6e3b..69529eea1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -60,7 +60,7 @@ export const es: TranslationResources = { workspaces: "Espacios de trabajo", agents: "Agentes", newAgent: "Nuevo agente", - openProject: "Abrir proyecto", + addProject: "Agregar proyecto", home: "Hogar", }, }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8c7f48495..eb27e6c1a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -61,7 +61,7 @@ export const fr: TranslationResources = { workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", - openProject: "Projet ouvert", + addProject: "Ajouter un projet", home: "Maison", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3b703aa93..1ed9eabed 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -60,7 +60,7 @@ export const ja: TranslationResources = { workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", - openProject: "プロジェクトを開く", + addProject: "プロジェクトを追加", home: "ホーム", }, }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 755d46265..5d292c9f2 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -60,7 +60,7 @@ export const ptBR: TranslationResources = { workspaces: "Espaços de trabalho", agents: "Agentes", newAgent: "Novo agente", - openProject: "Abrir projeto", + addProject: "Adicionar projeto", home: "Início", }, }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ed5313e67..268dd2a87 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -60,7 +60,7 @@ export const ru: TranslationResources = { workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", - openProject: "Открыть проект", + addProject: "Добавить проект", home: "Дом", }, }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4c788fce8..ceb49d858 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -60,7 +60,7 @@ export const zhCN: TranslationResources = { workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", - openProject: "打开项目", + addProject: "添加 project", home: "首页", }, }, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index b4034e81b..842b79a53 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -54,7 +54,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; -import { useProjectPickerStore } from "@/stores/project-picker-store"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore, @@ -1624,7 +1624,7 @@ export function NewWorkspaceScreen({ const [manualPickerSelection, setManualPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); - const openAddProjectPicker = useProjectPickerStore((state) => state.open); + const openAddProjectPicker = useOpenAddProject(); const [isolationPickerOpen, setIsolationPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 371ffaa29..20ae9e04d 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -7,7 +7,7 @@ import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { CommunityLinks } from "@/components/community-links"; import { MenuHeader } from "@/components/headers/menu-header"; -import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useHostChooser } from "@/hosts/host-chooser"; import { usePanelStore } from "@/stores/panel-store"; import { @@ -29,7 +29,7 @@ export function OpenProjectScreen() { const { t } = useTranslation(); const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); - const openProjectPicker = useOpenProjectPicker(); + const openProjectPicker = useOpenAddProject(); const chooseHost = useHostChooser(); const localServerId = useLocalDaemonServerId(); const [importServerId, setImportServerId] = useState(null); diff --git a/packages/app/src/stores/add-project-flow-store.ts b/packages/app/src/stores/add-project-flow-store.ts new file mode 100644 index 000000000..49eabbb90 --- /dev/null +++ b/packages/app/src/stores/add-project-flow-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +export interface AddProjectFlowRequest { + id: number; + preferredHostId?: string; +} + +interface AddProjectFlowStoreState { + request: AddProjectFlowRequest | null; + open: (preferredHostId?: string) => void; + close: () => void; +} + +let nextRequestId = 1; + +export const useAddProjectFlowStore = create((set) => ({ + request: null, + open: (preferredHostId) => { + set({ + request: { + id: nextRequestId++, + ...(preferredHostId ? { preferredHostId } : {}), + }, + }); + }, + close: () => set({ request: null }), +})); diff --git a/packages/app/src/stores/project-picker-store.ts b/packages/app/src/stores/project-picker-store.ts deleted file mode 100644 index c5485502e..000000000 --- a/packages/app/src/stores/project-picker-store.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { create } from "zustand"; - -interface ProjectPickerRequest { - serverId: string; -} - -interface ProjectPickerStoreState { - request: ProjectPickerRequest | null; - open: (serverId: string) => void; - close: () => void; -} - -export const useProjectPickerStore = create((set) => ({ - request: null, - open: (serverId) => set({ request: { serverId } }), - close: () => set({ request: null }), -})); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index a636fa4d5..252ed1457 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2258,6 +2258,136 @@ test("sends project.add.request without creating a workspace", async () => { }); }); +test("searches GitHub repositories through the dotted RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const searchPromise = client.searchGithubRepositories( + { query: "paseo", limit: 10 }, + "req-repositories", + ); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.github.search_repositories.request", + query: "paseo", + limit: 10, + requestId: "req-repositories", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.github.search_repositories.response", + payload: { + status: "success", + requestId: "req-repositories", + repositories: [ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ], + available: true, + error: null, + }, + }), + ); + + await expect(searchPromise).resolves.toEqual({ + status: "success", + requestId: "req-repositories", + repositories: [ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ], + available: true, + error: null, + }); +}); + +test("creates and registers a project directory through the dotted RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createProjectDirectory( + { parentPath: "/tmp/projects", name: "new-project" }, + "req-create-project-directory", + ); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "project.create_directory.request", + parentPath: "/tmp/projects", + name: "new-project", + requestId: "req-create-project-directory", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "project.create_directory.response", + payload: { + requestId: "req-create-project-directory", + directoryPath: "/tmp/projects/new-project", + project: { + projectId: "directory:/tmp/projects/new-project", + projectDisplayName: "new-project", + projectCustomName: null, + projectRootPath: "/tmp/projects/new-project", + projectKind: "non_git", + }, + error: null, + errorCode: null, + }, + }), + ); + + await expect(createPromise).resolves.toEqual({ + requestId: "req-create-project-directory", + directoryPath: "/tmp/projects/new-project", + project: { + projectId: "directory:/tmp/projects/new-project", + projectDisplayName: "new-project", + projectCustomName: null, + projectRootPath: "/tmp/projects/new-project", + projectKind: "non_git", + }, + error: null, + errorCode: null, + }); +}); + test("sends first-agent prompt context with workspace.create.request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index fdb905b13..22d656e50 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -55,7 +55,9 @@ import type { PaseoWorktreeArchiveResponse, ProjectIconResponse, ProjectAddResponse, + ProjectCreateDirectoryResponse, OpenProjectResponseMessage, + WorkspaceGithubSearchRepositoriesResponse, WorkspaceGithubCloneProtocol, WorkspaceGithubCloneResponse, ArchiveWorkspaceResponseMessage, @@ -763,6 +765,9 @@ export interface RenameTerminalInput { } type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ProjectAddPayload = ProjectAddResponse["payload"]; +export type ProjectCreateDirectoryPayload = ProjectCreateDirectoryResponse["payload"]; +export type WorkspaceGithubSearchRepositoriesPayload = + WorkspaceGithubSearchRepositoriesResponse["payload"]; type WorkspaceGithubClonePayload = WorkspaceGithubCloneResponse["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; @@ -1994,6 +1999,36 @@ export class DaemonClient { }); } + async createProjectDirectory( + input: { parentPath: string; name: string }, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"project.create_directory.response">({ + requestId, + message: { + type: "project.create_directory.request", + parentPath: input.parentPath, + name: input.name, + }, + }); + } + + async searchGithubRepositories( + input: { query: string; limit?: number }, + requestId?: string, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest<"workspace.github.search_repositories.response">( + { + requestId, + message: { + type: "workspace.github.search_repositories.request", + query: input.query, + limit: input.limit, + }, + }, + ); + } + async cloneGithubWorkspace( input: { repo: string; targetDirectory: string; cloneProtocol?: WorkspaceGithubCloneProtocol }, requestId?: string, diff --git a/packages/desktop/src/features/dialogs.ts b/packages/desktop/src/features/dialogs.ts index 7f6fd934f..5e2b0ba76 100644 --- a/packages/desktop/src/features/dialogs.ts +++ b/packages/desktop/src/features/dialogs.ts @@ -16,6 +16,7 @@ interface OpenOptions { title?: string; defaultPath?: string; directory?: boolean; + createDirectory?: boolean; multiple?: boolean; filters?: Array<{ name: string; extensions: string[] }>; } @@ -65,6 +66,7 @@ export function registerDialogHandlers(): void { const win = BrowserWindow.fromWebContents(event.sender); const properties: Electron.OpenDialogOptions["properties"] = []; if (options?.directory) properties.push("openDirectory"); + if (options?.createDirectory) properties.push("createDirectory"); if (options?.multiple) properties.push("multiSelections"); if (!options?.directory) properties.push("openFile"); diff --git a/packages/protocol/src/messages.project-command-center.test.ts b/packages/protocol/src/messages.project-command-center.test.ts new file mode 100644 index 000000000..22660fbb5 --- /dev/null +++ b/packages/protocol/src/messages.project-command-center.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + parseServerInfoStatusPayload, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +describe("project command-center protocol", () => { + it("parses the dotted GitHub repository search request and normalized success response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.github.search_repositories.request", + query: "paseo", + limit: 12, + requestId: "req-search", + }), + ).toEqual({ + type: "workspace.github.search_repositories.request", + query: "paseo", + limit: 12, + requestId: "req-search", + }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.github.search_repositories.response", + payload: { + status: "success", + requestId: "req-search", + repositories: [ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ], + available: true, + error: null, + }, + }).payload, + ).toEqual({ + status: "success", + requestId: "req-search", + repositories: [ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ], + available: true, + error: null, + }); + }); + + it.each([ + { status: "unavailable", reason: "gh_missing", message: "gh is missing" }, + { status: "unauthenticated", message: "sign in" }, + { status: "error", message: "command failed" }, + ])("parses the GitHub $status runtime state", (payload) => { + expect( + SessionOutboundMessageSchema.safeParse({ + type: "workspace.github.search_repositories.response", + payload: { + requestId: "req-search", + repositories: [], + available: payload.status === "error", + error: payload.message, + ...payload, + }, + }).success, + ).toBe(true); + }); + + it("parses atomic project directory creation request and response", () => { + expect( + SessionInboundMessageSchema.safeParse({ + type: "project.create_directory.request", + parentPath: "/Users/example/dev", + name: "new-project", + requestId: "req-create", + }).success, + ).toBe(true); + + expect( + SessionOutboundMessageSchema.parse({ + type: "project.create_directory.response", + payload: { + requestId: "req-create", + directoryPath: "/Users/example/dev/new-project", + project: { + projectId: "directory:/Users/example/dev/new-project", + projectDisplayName: "new-project", + projectCustomName: null, + projectRootPath: "/Users/example/dev/new-project", + projectKind: "non_git", + }, + error: null, + errorCode: null, + }, + }).payload.project?.projectDisplayName, + ).toBe("new-project"); + + expect( + SessionInboundMessageSchema.safeParse({ + type: "project.create_directory.request", + parentPath: "", + name: "", + requestId: "req-invalid-create", + }).success, + ).toBe(true); + }); + + it("accepts future project directory error codes without transforming wire values", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "project.create_directory.response", + payload: { + requestId: "req-create", + directoryPath: null, + project: null, + error: "A newer failure", + errorCode: "future_failure_reason", + }, + }).payload.errorCode, + ).toBe("future_failure_reason"); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.github.search_repositories.response", + payload: { + status: "success", + requestId: "req-search", + repositories: [ + { + id: "repo", + name: " paseo ", + nameWithOwner: " getpaseo/paseo ", + description: null, + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: " https://github.com/getpaseo/paseo ", + }, + ], + available: true, + error: null, + }, + }).payload.repositories[0]?.name, + ).toBe(" paseo "); + }); + + it("keeps both feature flags optional for older server_info payloads", () => { + const parsed = parseServerInfoStatusPayload({ + status: "server_info", + serverId: "server-old", + features: {}, + }); + + expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined(); + expect(parsed.features?.projectCreateDirectory).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 965b95aec..7325c5af8 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1789,14 +1789,39 @@ export const OpenProjectRequestSchema = z.object({ requestId: z.string(), }); +// Smallest shorthand repo path is "a/b": owner, slash, repository. +const MIN_REPOSITORY_PATH_LENGTH = 3; + export const ProjectAddRequestSchema = z.object({ type: z.literal("project.add.request"), cwd: z.string(), requestId: z.string(), }); -// Smallest shorthand repo path is "a/b": owner, slash, repository. -const MIN_REPOSITORY_PATH_LENGTH = 3; +export const ProjectCreateDirectoryRequestSchema = z.object({ + type: z.literal("project.create_directory.request"), + parentPath: z.string(), + name: z.string(), + requestId: z.string(), +}); + +export const GithubRepositorySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + nameWithOwner: z.string().min(MIN_REPOSITORY_PATH_LENGTH), + description: z.string().nullable(), + visibility: z.enum(["public", "private", "internal"]), + updatedAt: z.string(), + cloneUrl: z.string().min(MIN_REPOSITORY_PATH_LENGTH), +}); + +export const WorkspaceGithubSearchRepositoriesRequestSchema = z.object({ + type: z.literal("workspace.github.search_repositories.request"), + query: z.string(), + limit: z.number().int().min(1).max(50).optional(), + requestId: z.string(), +}); + export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]); export const WorkspaceGithubCloneRequestSchema = z.object({ @@ -2191,6 +2216,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ProjectAddRequestSchema, + ProjectCreateDirectoryRequestSchema, + WorkspaceGithubSearchRepositoriesRequestSchema, WorkspaceGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, @@ -2438,6 +2465,10 @@ export const ServerInfoStatusPayloadSchema = z workspacePinning: z.boolean().optional(), // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. workspaceGithubClone: z.boolean().optional(), + // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. + workspaceGithubRepositorySearch: z.boolean().optional(), + // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. + projectCreateDirectory: z.boolean().optional(), }) .optional(), }) @@ -2936,6 +2967,63 @@ export const ProjectAddResponseSchema = z.object({ }), }); +export const ProjectCreateDirectoryErrorCodeSchema = z.enum([ + "invalid_name", + "parent_directory_not_found", + "directory_exists", + "permission_denied", + "registration_failed", + "filesystem_error", +]); + +export const ProjectCreateDirectoryResponseSchema = z.object({ + type: z.literal("project.create_directory.response"), + payload: z.object({ + requestId: z.string(), + directoryPath: z.string().nullable(), + project: WorkspaceProjectDescriptorPayloadSchema.nullable(), + error: z.string().nullable(), + // Error codes are open-ended on the wire so older clients can still parse + // responses after a newer daemon learns another failure reason. + errorCode: z.string().nullable(), + }), +}); + +export const WorkspaceGithubSearchRepositoriesResponseSchema = z.object({ + type: z.literal("workspace.github.search_repositories.response"), + payload: z.discriminatedUnion("status", [ + z.object({ + status: z.literal("success"), + requestId: z.string(), + repositories: z.array(GithubRepositorySchema), + available: z.literal(true), + error: z.null(), + }), + z.object({ + status: z.literal("unavailable"), + requestId: z.string(), + repositories: z.array(GithubRepositorySchema), + reason: z.literal("gh_missing"), + available: z.literal(false), + error: z.string(), + }), + z.object({ + status: z.literal("unauthenticated"), + requestId: z.string(), + repositories: z.array(GithubRepositorySchema), + available: z.literal(false), + error: z.string(), + }), + z.object({ + status: z.literal("error"), + requestId: z.string(), + repositories: z.array(GithubRepositorySchema), + available: z.literal(true), + error: z.string(), + }), + ]), +}); + export const WorkspaceGithubCloneResponseSchema = z.object({ type: z.literal("workspace.github.clone.response"), payload: z.object({ @@ -4368,7 +4456,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchRecentProviderSessionsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, ProjectAddResponseSchema, + ProjectCreateDirectoryResponseSchema, OpenProjectResponseMessageSchema, + WorkspaceGithubSearchRepositoriesResponseSchema, WorkspaceGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, @@ -4527,8 +4617,13 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer< >; export type FetchWorkspacesResponseMessage = z.infer; export type ProjectAddResponse = z.infer; +export type ProjectCreateDirectoryResponse = z.infer; export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; +export type WorkspaceGithubSearchRepositoriesResponse = z.infer< + typeof WorkspaceGithubSearchRepositoriesResponseSchema +>; +export type GithubRepository = z.infer; export type WorkspaceGithubCloneResponse = z.infer; export type StartWorkspaceScriptResponseMessage = z.infer< typeof StartWorkspaceScriptResponseMessageSchema @@ -4778,6 +4873,11 @@ export type LegacyListAvailableEditorsRequest = z.infer< export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ProjectAddRequest = z.infer; +export type ProjectCreateDirectoryRequest = z.infer; +export type ProjectCreateDirectoryErrorCode = z.infer; +export type WorkspaceGithubSearchRepositoriesRequest = z.infer< + typeof WorkspaceGithubSearchRepositoriesRequestSchema +>; export type WorkspaceGithubCloneRequest = z.infer; export type WorkspaceGithubCloneProtocol = z.infer; export type ArchiveWorkspaceRequest = z.infer; diff --git a/packages/server/src/server/project-directory-service.test.ts b/packages/server/src/server/project-directory-service.test.ts new file mode 100644 index 000000000..b7231b555 --- /dev/null +++ b/packages/server/src/server/project-directory-service.test.ts @@ -0,0 +1,122 @@ +import { access, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createProjectDirectory, + ProjectDirectoryRequestError, + validateDirectoryName, +} from "./project-directory-service.js"; + +describe("project directory creation", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "paseo-project-directory-")); + roots.push(root); + return root; + } + + it.each(["", " ", ".", "..", "nested/name", "nested\\name", "/absolute", "C:\\absolute"])( + "rejects invalid single directory name %j", + (name) => { + expect(() => validateDirectoryName(name)).toThrow(ProjectDirectoryRequestError); + }, + ); + + it("requires the selected parent directory to already exist", async () => { + const root = await createRoot(); + + await expect( + createProjectDirectory( + { parentPath: join(root, "missing"), name: "project" }, + { + registerProject: async () => { + throw new Error("registration should not be attempted"); + }, + }, + ), + ).rejects.toMatchObject({ code: "parent_directory_not_found" }); + }); + + it("does not substitute the daemon cwd for a missing parent selection", async () => { + await expect( + createProjectDirectory( + { parentPath: " ", name: "project" }, + { + registerProject: async () => { + throw new Error("registration should not be attempted"); + }, + }, + ), + ).rejects.toMatchObject({ + code: "parent_directory_not_found", + message: "Parent directory is required", + }); + }); + + it("rejects collisions without changing the existing directory", async () => { + const root = await createRoot(); + const existing = join(root, "existing"); + await mkdir(existing); + let registrationAttempted = false; + + await expect( + createProjectDirectory( + { parentPath: root, name: "existing" }, + { + registerProject: async () => { + registrationAttempted = true; + throw new Error("registration should not be attempted"); + }, + }, + ), + ).rejects.toMatchObject({ code: "directory_exists", directoryPath: existing }); + await expect(access(existing)).resolves.toBeUndefined(); + expect(registrationAttempted).toBe(false); + }); + + it("rolls back the newly created directory when registration fails", async () => { + const root = await createRoot(); + const directoryPath = join(root, "unregistered"); + + await expect( + createProjectDirectory( + { parentPath: root, name: "unregistered" }, + { + registerProject: async () => { + throw new Error("registry unavailable"); + }, + }, + ), + ).rejects.toMatchObject({ code: "registration_failed", directoryPath }); + await expect(access(directoryPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("returns the registered project and created path together", async () => { + const root = await createRoot(); + const directoryPath = join(root, "new-project"); + const project = { + projectId: `directory:${directoryPath}`, + rootPath: directoryPath, + kind: "non_git" as const, + displayName: "new-project", + customName: null, + createdAt: "2026-07-15T10:00:00Z", + updatedAt: "2026-07-15T10:00:00Z", + archivedAt: null, + }; + + await expect( + createProjectDirectory( + { parentPath: root, name: "new-project" }, + { registerProject: async () => project }, + ), + ).resolves.toEqual({ directoryPath, project }); + await expect(access(directoryPath)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/project-directory-service.ts b/packages/server/src/server/project-directory-service.ts new file mode 100644 index 000000000..499aa44ef --- /dev/null +++ b/packages/server/src/server/project-directory-service.ts @@ -0,0 +1,215 @@ +import { mkdir, rmdir, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep, win32 } from "node:path"; +import type { ProjectCreateDirectoryErrorCode } from "@getpaseo/protocol/messages"; +import { expandTilde } from "../utils/path.js"; +import type { PersistedProjectRecord } from "./workspace-registry.js"; + +export class ProjectDirectoryRequestError extends Error { + constructor( + readonly code: ProjectCreateDirectoryErrorCode, + message: string, + readonly directoryPath: string | null = null, + ) { + super(message); + this.name = "ProjectDirectoryRequestError"; + } +} + +export interface CreateProjectDirectoryInput { + parentPath: string; + name: string; +} + +export interface CreateProjectDirectoryResult { + directoryPath: string; + project: PersistedProjectRecord; +} + +interface ProjectDirectoryFileSystem { + stat(path: string): Promise<{ isDirectory(): boolean }>; + mkdir(path: string): Promise; + rmdir(path: string): Promise; +} + +interface CreateProjectDirectoryDependencies { + filesystem?: ProjectDirectoryFileSystem; + registerProject(directoryPath: string): Promise; +} + +const nodeProjectDirectoryFileSystem: ProjectDirectoryFileSystem = { + stat, + async mkdir(path) { + await mkdir(path); + }, + rmdir, +}; + +export async function createProjectDirectory( + input: CreateProjectDirectoryInput, + dependencies: CreateProjectDirectoryDependencies, +): Promise { + validateDirectoryName(input.name); + + const requestedParentPath = input.parentPath.trim(); + if (!requestedParentPath) { + throw new ProjectDirectoryRequestError( + "parent_directory_not_found", + "Parent directory is required", + ); + } + const parentDirectory = resolve(expandTilde(requestedParentPath)); + const directoryPath = resolve(parentDirectory, input.name); + if (!isPathInside(parentDirectory, directoryPath)) { + throw new ProjectDirectoryRequestError( + "invalid_name", + "Directory name must resolve inside the selected parent", + ); + } + + const filesystem = dependencies.filesystem ?? nodeProjectDirectoryFileSystem; + await requireExistingParent(filesystem, parentDirectory); + + try { + await filesystem.mkdir(directoryPath); + } catch (error) { + throw mapCreateDirectoryError(error, directoryPath); + } + + try { + const project = await dependencies.registerProject(directoryPath); + return { directoryPath, project }; + } catch (registrationError) { + try { + await filesystem.rmdir(directoryPath); + } catch (rollbackError) { + throw new ProjectDirectoryRequestError( + "registration_failed", + `Failed to register project and roll back directory: ${errorMessage(rollbackError)}`, + directoryPath, + ); + } + throw new ProjectDirectoryRequestError( + "registration_failed", + `Failed to register project: ${errorMessage(registrationError)}`, + directoryPath, + ); + } +} + +export function validateDirectoryName(name: string): void { + if (name.trim().length === 0) { + throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be empty"); + } + if (name !== name.trim()) { + throw new ProjectDirectoryRequestError( + "invalid_name", + "Directory name cannot start or end with whitespace", + ); + } + if (name === "." || name === "..") { + throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be . or .."); + } + if (name.includes("/") || name.includes("\\") || name.includes("\0")) { + throw new ProjectDirectoryRequestError( + "invalid_name", + "Directory name must be a single name without path separators", + ); + } + if (isAbsolute(name) || win32.isAbsolute(name) || /^[A-Za-z]:/.test(name)) { + throw new ProjectDirectoryRequestError( + "invalid_name", + "Directory name cannot be an absolute path", + ); + } +} + +async function requireExistingParent( + filesystem: ProjectDirectoryFileSystem, + parentDirectory: string, +): Promise { + try { + const parent = await filesystem.stat(parentDirectory); + if (parent.isDirectory()) { + return; + } + throw new ProjectDirectoryRequestError( + "parent_directory_not_found", + `Parent directory not found: ${parentDirectory}`, + ); + } catch (error) { + if (error instanceof ProjectDirectoryRequestError) { + throw error; + } + const code = nodeErrorCode(error); + if (code === "EACCES" || code === "EPERM") { + throw new ProjectDirectoryRequestError( + "permission_denied", + `Permission denied reading parent directory: ${parentDirectory}`, + ); + } + if (code === "ENOENT" || code === "ENOTDIR") { + throw new ProjectDirectoryRequestError( + "parent_directory_not_found", + `Parent directory not found: ${parentDirectory}`, + ); + } + throw new ProjectDirectoryRequestError( + "filesystem_error", + `Failed to inspect parent directory: ${errorMessage(error)}`, + ); + } +} + +function mapCreateDirectoryError( + error: unknown, + directoryPath: string, +): ProjectDirectoryRequestError { + const code = nodeErrorCode(error); + if (code === "EEXIST") { + return new ProjectDirectoryRequestError( + "directory_exists", + `Directory already exists: ${directoryPath}`, + directoryPath, + ); + } + if (code === "ENOENT" || code === "ENOTDIR") { + return new ProjectDirectoryRequestError( + "parent_directory_not_found", + `Parent directory not found: ${resolve(directoryPath, "..")}`, + directoryPath, + ); + } + if (code === "EACCES" || code === "EPERM" || code === "EROFS") { + return new ProjectDirectoryRequestError( + "permission_denied", + `Permission denied creating directory: ${directoryPath}`, + directoryPath, + ); + } + return new ProjectDirectoryRequestError( + "filesystem_error", + `Failed to create directory: ${errorMessage(error)}`, + directoryPath, + ); +} + +function isPathInside(parentDirectory: string, directoryPath: string): boolean { + const relativePath = relative(parentDirectory, directoryPath); + return ( + relativePath.length > 0 && + relativePath !== ".." && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ); +} + +function nodeErrorCode(error: unknown): string | null { + if (!error || typeof error !== "object" || !("code" in error)) { + return null; + } + return typeof error.code === "string" ? error.code : null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ba7fabe7e..40b80a01e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,5 +1,5 @@ import { execSync } from "child_process"; -import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join, resolve as resolvePath } from "path"; import pino from "pino"; @@ -41,6 +41,11 @@ import type { GitHubPullRequestStatusFacts, GitHubService, } from "../services/github-service.js"; +import { + GitHubAuthenticationError, + GitHubCliMissingError, + GitHubCommandError, +} from "../services/github-service.js"; interface SessionHandlerInternals { interruptAgentIfRunning(agentId: string): Promise; @@ -273,6 +278,7 @@ interface SessionForTestOptions { github?: Partial; checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; workspaceGitService?: { + getCheckout?: ReturnType; getCheckoutDiff?: ReturnType; getSnapshot?: ReturnType; suggestBranchesForCwd?: ReturnType; @@ -315,6 +321,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { scheduleRefreshForCwd: vi.fn(), }; const workspaceGitService = options.workspaceGitService ?? { + getCheckout: vi.fn(), getCheckoutDiff: vi.fn(), getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn(), @@ -387,6 +394,223 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { }); } +describe("project command-center RPCs", () => { + test("returns normalized repositories from the host GitHub service", async () => { + const messages: SessionOutboundMessage[] = []; + const searchRepositories = vi.fn().mockResolvedValue([ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ]); + const session = createSessionForTest({ messages, github: { searchRepositories } }); + + await session.handleMessage({ + type: "workspace.github.search_repositories.request", + query: "paseo", + limit: 10, + requestId: "req-repositories", + }); + + expect(searchRepositories).toHaveBeenCalledWith({ + cwd: expect.any(String), + query: "paseo", + limit: 10, + }); + expect(messages).toEqual([ + { + type: "workspace.github.search_repositories.response", + payload: { + status: "success", + requestId: "req-repositories", + repositories: [ + { + id: "R_paseo", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: "Development environment in your pocket", + visibility: "public", + updatedAt: "2026-07-15T10:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ], + available: true, + error: null, + }, + }, + ]); + }); + + test.each([ + { + error: new GitHubCliMissingError(), + expected: { + status: "unavailable", + requestId: "req-repositories-error", + reason: "gh_missing", + repositories: [], + available: false, + error: "GitHub CLI (gh) is not installed or not in PATH", + }, + }, + { + error: new GitHubAuthenticationError({ stderr: "gh auth login" }), + expected: { + status: "unauthenticated", + requestId: "req-repositories-error", + repositories: [], + available: false, + error: "GitHub CLI is not authenticated. Run gh auth login on the host.", + }, + }, + { + error: new GitHubCommandError({ + args: ["search", "repos", "paseo"], + cwd: "/tmp", + exitCode: 1, + stderr: "GitHub API unavailable", + }), + expected: { + status: "error", + requestId: "req-repositories-error", + repositories: [], + available: true, + error: "GitHub API unavailable", + }, + }, + ])("maps GitHub runtime failures to $expected.status", async ({ error, expected }) => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + github: { searchRepositories: vi.fn().mockRejectedValue(error) }, + }); + + await session.handleMessage({ + type: "workspace.github.search_repositories.request", + query: "paseo", + requestId: "req-repositories-error", + }); + + expect(messages).toEqual([ + { + type: "workspace.github.search_repositories.response", + payload: expected, + }, + ]); + }); + + test("creates a directory and returns its normalized Project descriptor", async () => { + const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-"))); + const directoryPath = join(parentDirectory, "new-project"); + const messages: SessionOutboundMessage[] = []; + const projectUpsert = vi.fn().mockResolvedValue(undefined); + const session = createSessionForTest({ + messages, + projectRegistry: { + list: vi.fn().mockResolvedValue([]), + upsert: projectUpsert, + }, + workspaceGitService: { + getCheckout: vi.fn(async (cwd: string) => ({ + cwd, + isGit: false as const, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, + })), + }, + }); + + try { + await session.handleMessage({ + type: "project.create_directory.request", + parentPath: parentDirectory, + name: "new-project", + requestId: "req-create-directory", + }); + + expect(existsSync(directoryPath)).toBe(true); + expect(projectUpsert).toHaveBeenCalledOnce(); + expect(messages).toEqual([ + { + type: "project.create_directory.response", + payload: { + requestId: "req-create-directory", + directoryPath, + project: { + projectId: directoryPath, + projectDisplayName: "new-project", + projectCustomName: null, + projectRootPath: directoryPath, + projectKind: "non_git", + }, + error: null, + errorCode: null, + }, + }, + ]); + } finally { + rmSync(parentDirectory, { recursive: true, force: true }); + } + }); + + test("rolls back the directory when Project registration fails", async () => { + const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-"))); + const directoryPath = join(parentDirectory, "unregistered"); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + projectRegistry: { + list: vi.fn().mockResolvedValue([]), + upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")), + }, + workspaceGitService: { + getCheckout: vi.fn(async (cwd: string) => ({ + cwd, + isGit: false as const, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, + })), + }, + }); + + try { + await session.handleMessage({ + type: "project.create_directory.request", + parentPath: parentDirectory, + name: "unregistered", + requestId: "req-registration-failure", + }); + + expect(existsSync(directoryPath)).toBe(false); + expect(messages).toEqual([ + { + type: "project.create_directory.response", + payload: { + requestId: "req-registration-failure", + directoryPath, + project: null, + error: "Failed to register project: registry unavailable", + errorCode: "registration_failed", + }, + }, + ]); + } finally { + rmSync(parentDirectory, { recursive: true, force: true }); + } + }); +}); + describe("file explorer binary responses", () => { const tempDirs: string[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 48e44ab66..87ea2a8e2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -184,7 +184,13 @@ import type pino from "pino"; import { FileBackedChatService } from "./chat/chat-service.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; -import { createGitHubService, type GitHubService } from "../services/github-service.js"; +import { + createGitHubService, + GitHubAuthenticationError, + GitHubCliMissingError, + GitHubCommandError, + type GitHubService, +} from "../services/github-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { summarizeFetchWorkspacesEntries, @@ -217,6 +223,10 @@ import { toWorktreeWireError, } from "./worktree-errors.js"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import { + createProjectDirectory, + ProjectDirectoryRequestError, +} from "./project-directory-service.js"; import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -362,6 +372,10 @@ type FetchWorkspacesResponsePayload = Extract< type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number]; type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"]; type WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number]; +type WorkspaceGithubSearchRepositoriesResponsePayload = Extract< + SessionOutboundMessage, + { type: "workspace.github.search_repositories.response" } +>["payload"]; type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } @@ -1632,6 +1646,10 @@ export class Session { return this.handleOpenProjectRequest(msg); case "project.add.request": return this.handleProjectAddRequest(msg); + case "project.create_directory.request": + return this.handleProjectCreateDirectoryRequest(msg); + case "workspace.github.search_repositories.request": + return this.handleWorkspaceGithubSearchRepositoriesRequest(msg); case "workspace.github.clone.request": return this.handleWorkspaceGithubCloneRequest(msg); case "archive_workspace_request": @@ -4770,6 +4788,128 @@ export class Session { } } + private async handleProjectCreateDirectoryRequest( + request: Extract, + ): Promise { + try { + const result = await createProjectDirectory( + { parentPath: request.parentPath, name: request.name }, + { + registerProject: (directoryPath) => + this.workspaceProvisioning.findOrCreateProjectForDirectory(directoryPath), + }, + ); + this.emit({ + type: "project.create_directory.response", + payload: { + requestId: request.requestId, + directoryPath: result.directoryPath, + project: this.buildProjectDescriptor(result.project), + error: null, + errorCode: null, + }, + }); + } catch (error) { + const requestError = + error instanceof ProjectDirectoryRequestError + ? error + : new ProjectDirectoryRequestError( + "registration_failed", + error instanceof Error ? error.message : "Failed to create project directory", + ); + this.sessionLogger.error( + { + err: error, + parentPath: request.parentPath, + name: request.name, + errorCode: requestError.code, + }, + "Failed to create project directory", + ); + this.emit({ + type: "project.create_directory.response", + payload: { + requestId: request.requestId, + directoryPath: requestError.directoryPath, + project: null, + error: requestError.message, + errorCode: requestError.code, + }, + }); + } + } + + private async handleWorkspaceGithubSearchRepositoriesRequest( + request: Extract< + SessionInboundMessage, + { type: "workspace.github.search_repositories.request" } + >, + ): Promise { + try { + const repositories = await this.github.searchRepositories({ + cwd: homedir(), + query: request.query, + limit: request.limit, + }); + this.emit({ + type: "workspace.github.search_repositories.response", + payload: { + requestId: request.requestId, + repositories, + status: "success", + available: true, + error: null, + }, + }); + } catch (error) { + const missing = error instanceof GitHubCliMissingError; + const unauthenticated = error instanceof GitHubAuthenticationError; + const commandError = error instanceof GitHubCommandError ? error.stderr.trim() : ""; + let message: string; + if (missing) { + message = "GitHub CLI (gh) is not installed or not in PATH"; + } else if (unauthenticated) { + message = "GitHub CLI is not authenticated. Run gh auth login on the host."; + } else if (commandError) { + message = commandError; + } else { + message = error instanceof Error ? error.message : "GitHub search failed"; + } + let payload: WorkspaceGithubSearchRepositoriesResponsePayload; + if (missing) { + payload = { + status: "unavailable", + requestId: request.requestId, + repositories: [], + reason: "gh_missing", + available: false, + error: message, + }; + } else if (unauthenticated) { + payload = { + status: "unauthenticated", + requestId: request.requestId, + repositories: [], + available: false, + error: message, + }; + } else { + payload = { + status: "error", + requestId: request.requestId, + repositories: [], + available: true, + error: message, + }; + } + this.sessionLogger.warn({ err: error }, "GitHub repository search failed"); + this.emit({ + type: "workspace.github.search_repositories.response", + payload, + }); + } + } + private async handleWorkspaceGithubCloneRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ff371979a..5f655fc51 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1268,6 +1268,10 @@ export class VoiceAssistantWebSocketServer { workspacePinning: true, // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. workspaceGithubClone: true, + // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. + workspaceGithubRepositorySearch: true, + // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. + projectCreateDirectory: true, }, }; } diff --git a/packages/server/src/services/github-repository-search.test.ts b/packages/server/src/services/github-repository-search.test.ts new file mode 100644 index 000000000..94e2ee068 --- /dev/null +++ b/packages/server/src/services/github-repository-search.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + createGitHubService, + type GitHubCommandRunner, + type GitHubCommandRunnerOptions, +} from "./github-service.js"; + +interface RunnerCall { + args: string[]; + options: GitHubCommandRunnerOptions; +} + +function createRunner(outputs: string[]): { calls: RunnerCall[]; runner: GitHubCommandRunner } { + const calls: RunnerCall[] = []; + return { + calls, + runner: async (args, options) => { + calls.push({ args, options }); + return { stdout: outputs.shift() ?? "", stderr: "" }; + }, + }; +} + +describe("GitHub repository search", () => { + it("lists recent owned repositories for an empty query and normalizes clone identity", async () => { + const runner = createRunner([ + JSON.stringify([ + { + id: " R_recent ", + name: " paseo ", + nameWithOwner: " getpaseo/paseo ", + description: null, + visibility: "PUBLIC", + updatedAt: "2026-07-15T12:00:00Z", + sshUrl: " git@github.com:getpaseo/paseo.git ", + url: "https://github.com/getpaseo/paseo", + }, + ]), + "ssh\n", + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + await expect( + service.searchRepositories({ cwd: "/tmp", query: " ", limit: 8 }), + ).resolves.toEqual([ + { + id: "R_recent", + name: "paseo", + nameWithOwner: "getpaseo/paseo", + description: null, + visibility: "public", + updatedAt: "2026-07-15T12:00:00Z", + cloneUrl: "git@github.com:getpaseo/paseo.git", + }, + ]); + expect(runner.calls).toEqual([ + { + args: [ + "repo", + "list", + "--json", + "id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url", + "--limit", + "8", + ], + options: { cwd: "/tmp" }, + }, + { + args: ["config", "get", "git_protocol", "--host", "github.com"], + options: { cwd: "/tmp" }, + }, + ]); + }); + + it("searches accessible repositories for a typed query", async () => { + const runner = createRunner([ + JSON.stringify([ + { + id: 42, + name: "private-repo", + fullName: "octo/private-repo", + description: "Private project", + visibility: "PRIVATE", + updatedAt: "2026-07-14T08:00:00Z", + url: "https://github.com/octo/private-repo", + }, + ]), + "https", + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + await expect( + service.searchRepositories({ cwd: "/tmp", query: " private project ", limit: 5 }), + ).resolves.toEqual([ + { + id: "42", + name: "private-repo", + nameWithOwner: "octo/private-repo", + description: "Private project", + visibility: "private", + updatedAt: "2026-07-14T08:00:00Z", + cloneUrl: "https://github.com/octo/private-repo", + }, + ]); + expect(runner.calls).toEqual([ + { + args: [ + "search", + "repos", + "private project", + "--json", + "id,name,fullName,description,visibility,updatedAt,url", + "--sort", + "updated", + "--order", + "desc", + "--limit", + "5", + ], + options: { cwd: "/tmp" }, + }, + { + args: ["config", "get", "git_protocol", "--host", "github.com"], + options: { cwd: "/tmp" }, + }, + ]); + }); +}); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 53e089522..b28027fb2 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -45,6 +45,27 @@ const GitHubPullRequestSummarySchema = z.object({ updatedAt: z.string().catch(""), }); +const GitHubRepositoryListItemSchema = z.object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + nameWithOwner: z.string(), + description: z.string().nullable().optional(), + visibility: z.string(), + updatedAt: z.string(), + sshUrl: z.string(), + url: z.string(), +}); + +const GitHubRepositorySearchItemSchema = z.object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + fullName: z.string(), + description: z.string().nullable().optional(), + visibility: z.string(), + updatedAt: z.string(), + url: z.string(), +}); + const PullRequestCheckRunNodeSchema = z.object({ __typename: z.literal("CheckRun"), databaseId: z.number().nullable().optional(), @@ -783,6 +804,22 @@ export interface GitHubSearchResult { githubFeaturesEnabled: boolean; } +export interface GitHubRepositorySummary { + id: string; + name: string; + nameWithOwner: string; + description: string | null; + visibility: "public" | "private" | "internal"; + updatedAt: string; + cloneUrl: string; +} + +export interface SearchGitHubRepositoriesOptions { + cwd: string; + query: string; + limit?: number; +} + export type SearchGitHubIssuesAndPrsOptions = { cwd: string; query: string; @@ -819,6 +856,7 @@ export interface GitHubService { ): Promise; getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise; searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise; + searchRepositories(options: SearchGitHubRepositoriesOptions): Promise; createPullRequest( options: CreateGitHubPullRequestOptions, ): Promise; @@ -1368,6 +1406,49 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }); }, + async searchRepositories(input) { + const limit = input.limit ?? 20; + const query = input.query.trim(); + if (query.length === 0) { + const [stdout, cloneProtocol] = await Promise.all([ + run( + [ + "repo", + "list", + "--json", + "id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url", + "--limit", + String(limit), + ], + { cwd: input.cwd }, + ), + resolveConfiguredCloneProtocol(input.cwd, run), + ]); + return parseRepositoryList(stdout, cloneProtocol); + } + + const [stdout, cloneProtocol] = await Promise.all([ + run( + [ + "search", + "repos", + query, + "--json", + "id,name,fullName,description,visibility,updatedAt,url", + "--sort", + "updated", + "--order", + "desc", + "--limit", + String(limit), + ], + { cwd: input.cwd }, + ), + resolveConfiguredCloneProtocol(input.cwd, run), + ]); + return parseRepositorySearch(stdout, cloneProtocol); + }, + async searchIssuesAndPrs(input) { if (input.force && !input.reason) { throw new Error("GitHubService forced read requires a reason"); @@ -2169,6 +2250,90 @@ function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] { return parsed.map(toPullRequestSummary); } +type GitHubCloneProtocol = "https" | "ssh"; + +async function resolveConfiguredCloneProtocol( + cwd: string, + run: (args: string[], options: GitHubCommandRunnerOptions) => Promise, +): Promise { + try { + const protocol = ( + await run(["config", "get", "git_protocol", "--host", "github.com"], { + cwd, + }) + ) + .trim() + .toLowerCase(); + return protocol === "ssh" ? "ssh" : "https"; + } catch (error) { + if (error instanceof GitHubCommandError) { + return "https"; + } + throw error; + } +} + +function parseRepositoryList( + stdout: string, + cloneProtocol: GitHubCloneProtocol, +): GitHubRepositorySummary[] { + const parsed = z.array(GitHubRepositoryListItemSchema).parse(JSON.parse(stdout || "[]")); + return parsed.map((repository) => + normalizeRepositorySummary({ + ...repository, + nameWithOwner: repository.nameWithOwner, + cloneUrl: cloneProtocol === "ssh" ? repository.sshUrl : repository.url, + }), + ); +} + +function parseRepositorySearch( + stdout: string, + cloneProtocol: GitHubCloneProtocol, +): GitHubRepositorySummary[] { + const parsed = z.array(GitHubRepositorySearchItemSchema).parse(JSON.parse(stdout || "[]")); + return parsed.map((repository) => + normalizeRepositorySummary({ + ...repository, + nameWithOwner: repository.fullName, + cloneUrl: + cloneProtocol === "ssh" ? `git@github.com:${repository.fullName}.git` : repository.url, + }), + ); +} + +function normalizeRepositorySummary(repository: { + id: string | number; + name: string; + nameWithOwner: string; + description?: string | null; + visibility: string; + updatedAt: string; + cloneUrl: string; +}): GitHubRepositorySummary { + const nameWithOwner = repository.nameWithOwner.trim(); + if (!nameWithOwner.includes("/")) { + throw new Error(`GitHub repository is missing owner identity: ${nameWithOwner}`); + } + return { + id: String(repository.id).trim(), + name: repository.name.trim(), + nameWithOwner, + description: repository.description ?? null, + visibility: normalizeRepositoryVisibility(repository.visibility), + updatedAt: repository.updatedAt, + cloneUrl: repository.cloneUrl.trim(), + }; +} + +function normalizeRepositoryVisibility(visibility: string): GitHubRepositorySummary["visibility"] { + const normalized = visibility.toLowerCase(); + if (normalized === "public" || normalized === "private" || normalized === "internal") { + return normalized; + } + throw new Error(`Unknown GitHub repository visibility: ${visibility}`); +} + function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary { return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}"))); }