Add a keyboard-driven project setup flow (#2097)

* feat(projects): add keyboard-driven project setup

* test(app): update add project loading assertion

* fix(projects): harden GitHub project setup
This commit is contained in:
Mohamed Boudra
2026-07-15 21:16:10 +02:00
committed by GitHub
parent 90e0a0e353
commit dfe3330ef8
52 changed files with 3851 additions and 1023 deletions

View File

@@ -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<void> {
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<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
async function removeCreatedProject(
pathname: string,
knownProjectId: string | null,
): Promise<void> {
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 });
}
});
});

View File

@@ -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 });
}
});
});

View File

@@ -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<string> {
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();
});
});

View File

@@ -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<Exclude<AddProjectMethod, "browse">, 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<void> {
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<Locator> {
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<void> {
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<void> {
const option = addProjectFlowMethod(page, method);
await expect(option).toBeVisible();
await option.click();
if (method !== "browse") {
await expectAddProjectPage(page, METHOD_DESTINATIONS[method]);
}
}

View File

@@ -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<void>;
}
async function getAvailablePort(): Promise<number> {
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<void> {
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<void>((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<void> {
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<IsolatedHostDaemon> {
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 });
},
};
}

View File

@@ -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();
}

View File

@@ -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-"]')

View File

@@ -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<string> {
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");

View File

@@ -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,
},
]);
});
});

View File

@@ -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<string, string>;
githubLocationDrafts: Record<string, { query: string; activeIndex: number }>;
}
export interface OpenAddProjectFlowInput {
hosts: AddProjectHost[];
preferredHostId?: string;
}
function searchPage<TKind extends AddProjectPage["kind"]>(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;
}

View File

@@ -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),
};
});
}

View File

@@ -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
<WorktreeSetupCalloutSource />
<CommandCenter />
<HostChooserModal />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<AddProjectFlow
key={addProjectRequest.id}
request={addProjectRequest}
onClose={handleClose}
/>
);
}
const resultList =
items.length === 0 ? (
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>

View File

@@ -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();

View File

@@ -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 (
<Button
variant="outline"
size="sm"
leftIcon={FolderOpen}
disabled={disabled}
onPress={handlePress}
>
{t("projectPicker.browse")}
</Button>
);
}

View File

@@ -1,5 +0,0 @@
import type { ProjectPickerBrowseButtonProps } from "./project-picker-browse-button.types";
export function ProjectPickerBrowseButton(_props: ProjectPickerBrowseButtonProps) {
return null;
}

View File

@@ -1,6 +0,0 @@
export interface ProjectPickerBrowseButtonProps {
serverId: string;
disabled: boolean;
onSelect: (path: string) => void;
onError: () => void;
}

View File

@@ -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 (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{shortenPath(path)}
</Text>
{option.kind === "path" ? (
<Text style={rowActionTextStyle}>{t("projectPicker.openPath")}</Text>
) : null}
</View>
</Pressable>
);
}
interface ProjectPickerResultsProps {
options: ProjectPickerOption[];
activeIndex: number;
isSubmitting: boolean;
openErrorMessage: string | null;
hasQuery: boolean;
isSearching: boolean;
emptyTextStyle: StyleProp<TextStyle>;
errorTextStyle: StyleProp<TextStyle>;
onSelect: (path: string) => void;
}
function ProjectPickerResults({
options,
activeIndex,
isSubmitting,
openErrorMessage,
hasQuery,
isSearching,
emptyTextStyle,
errorTextStyle,
onSelect,
}: ProjectPickerResultsProps) {
const { t } = useTranslation();
const canShowResultState = !isSubmitting && !openErrorMessage;
return (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{canShowResultState && options.length === 0 && !hasQuery ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{canShowResultState && isSearching ? (
<Text style={emptyTextStyle}>{t("projectPicker.searching")}</Text>
) : null}
{canShowResultState && !isSearching && options.length === 0 && hasQuery ? (
<Text style={emptyTextStyle}>{t("common.empty.noOptionsMatchSearch")}</Text>
) : null}
{canShowResultState && options.length > 0 ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={onSelect}
/>
))}
</>
) : null}
</ScrollView>
);
}
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 (
<Pressable style={buttonStyle} onPress={onPress} accessibilityRole="button">
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
interface GithubRepoFormProps {
repo: string;
cloneProtocol: WorkspaceGithubCloneProtocol | null;
targetDirectory: string;
needsCloneProtocol: boolean;
isSubmitting: boolean;
repoInputRef: RefObject<TextInput | null>;
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 (
<View style={styles.githubForm}>
<View style={styles.fieldGroup}>
<Text style={labelStyle}>GitHub repo</Text>
<TextInput
ref={repoInputRef}
value={repo}
onChangeText={onChangeRepo}
placeholder="owner/repo"
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="next"
/>
</View>
{needsCloneProtocol ? (
<View style={styles.fieldGroup}>
<Text style={labelStyle}>Clone protocol</Text>
<View style={styles.protocolRow}>
<CloneProtocolButton
label="HTTPS"
active={cloneProtocol === "https"}
onPress={onSelectHttps}
/>
<CloneProtocolButton
label="SSH"
active={cloneProtocol === "ssh"}
onPress={onSelectSsh}
/>
</View>
</View>
) : null}
<View style={styles.fieldGroup}>
<Text style={labelStyle}>Checkout directory</Text>
<TextInput
value={targetDirectory}
onChangeText={onChangeTargetDirectory}
placeholder={DEFAULT_CLONE_TARGET_DIRECTORY}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={onSubmit}
/>
</View>
</View>
);
}
interface ProjectPickerKeyboardNavigationInput {
open: boolean;
mode: ProjectPickerMode;
optionsLength: number;
onClose: () => void;
setActiveIndex: Dispatch<SetStateAction<number>>;
}
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<typeof useHostRuntimeClient>;
serverId: string | null;
openGithubRepo: ReturnType<typeof useOpenGithubRepo>;
close: () => void;
}
function useGithubCloneMode({ client, serverId, openGithubRepo, close }: GithubCloneModeInput) {
const [repo, setRepo] = useState("");
const [cloneProtocol, setCloneProtocol] = useState<WorkspaceGithubCloneProtocol | null>(null);
const [targetDirectory, setTargetDirectory] = useState(DEFAULT_CLONE_TARGET_DIRECTORY);
const [cloneErrorText, setCloneErrorText] = useState<string | null>(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<TextInput>(null);
const repoInputRef = useRef<TextInput>(null);
const [mode, setMode] = useState<ProjectPickerMode>("local");
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [openErrorReason, setOpenErrorReason] = useState<OpenProjectFailureReason | null>(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 (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View style={panelStyle}>
<View style={headerStyle}>
<View style={styles.modeRow}>
<Pressable style={modeButtonStyle("local")} onPress={handleSetLocalMode}>
<Folder size={15} color={theme.colors.foregroundMuted} />
<Text style={modeButtonTextStyle("local")}>Local folder</Text>
</Pressable>
{supportsGithubClone ? (
<Pressable style={modeButtonStyle("github")} onPress={handleSetGithubMode}>
<Github size={15} color={theme.colors.foregroundMuted} />
<Text style={modeButtonTextStyle("github")}>GitHub repo</Text>
</Pressable>
) : null}
</View>
{mode === "local" ? (
<View style={styles.localInputRow}>
<TextInput
testID="project-picker-input"
ref={inputRef}
value={query}
onChangeText={handleChangeQuery}
placeholder={t("projectPicker.placeholder")}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
editable={!isSubmitting}
returnKeyType="go"
onSubmitEditing={submitActiveOption}
/>
<ProjectPickerBrowseButton
serverId={serverId}
disabled={isSubmitting}
onSelect={handleSelectPath}
onError={handleBrowseError}
/>
</View>
) : (
<GithubRepoForm
repo={repo}
cloneProtocol={cloneProtocol}
targetDirectory={targetDirectory}
needsCloneProtocol={needsCloneProtocol}
isSubmitting={isSubmitting}
repoInputRef={repoInputRef}
onChangeRepo={handleChangeRepo}
onChangeTargetDirectory={handleChangeTargetDirectory}
onSelectHttps={handleSetHttpsProtocol}
onSelectSsh={handleSetSshProtocol}
onSubmit={handleCloneRepo}
/>
)}
</View>
{mode === "local" ? (
<ProjectPickerResults
options={options}
activeIndex={activeIndex}
isSubmitting={isSubmitting}
openErrorMessage={openErrorMessage}
hasQuery={hasQuery}
isSearching={isSearching}
emptyTextStyle={emptyTextStyle}
errorTextStyle={errorTextStyle}
onSelect={handleSelectPath}
/>
) : (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{cloneErrorText ? <Text style={cloneErrorTextStyle}>{cloneErrorText}</Text> : null}
{isSubmitting ? <Text style={emptyTextStyle}>Cloning repository...</Text> : null}
</ScrollView>
)}
</View>
</View>
</Modal>
);
}
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",
},
}));

View File

@@ -24,6 +24,7 @@ export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
createDirectory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;

View File

@@ -19,6 +19,7 @@ describe("pickDirectory", () => {
{
directory: true,
multiple: false,
createDirectory: true,
},
]);
});

View File

@@ -11,6 +11,7 @@ export async function pickDirectory(
const selection = await open({
directory: true,
multiple: false,
createDirectory: true,
});
if (selection === null) {
return null;

View File

@@ -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 };
}

View File

@@ -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<TextInput>(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,

View File

@@ -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<ActiveWorkspaceSelection | null>(null);

View File

@@ -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],
);
}

View File

@@ -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]);
}

View File

@@ -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");
});

View File

@@ -60,7 +60,7 @@ export const ar: TranslationResources = {
workspaces: "مساحات العمل",
agents: "الوكلاء",
newAgent: "وكيل جديد",
openProject: "مشروع مفتوح",
addProject: "إضافة مشروع",
home: "بيت",
},
},

View File

@@ -58,7 +58,7 @@ export const en = {
workspaces: "Workspaces",
agents: "Agents",
newAgent: "New agent",
openProject: "Open project",
addProject: "Add project",
home: "Home",
},
},

View File

@@ -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",
},
},

View File

@@ -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",
},
},

View File

@@ -60,7 +60,7 @@ export const ja: TranslationResources = {
workspaces: "ワークスペース",
agents: "エージェント",
newAgent: "新しいエージェント",
openProject: "プロジェクトを開く",
addProject: "プロジェクトを追加",
home: "ホーム",
},
},

View File

@@ -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",
},
},

View File

@@ -60,7 +60,7 @@ export const ru: TranslationResources = {
workspaces: "Рабочие пространства",
agents: "Агенты",
newAgent: "Новый агент",
openProject: "Открыть проект",
addProject: "Добавить проект",
home: "Дом",
},
},

View File

@@ -60,7 +60,7 @@ export const zhCN: TranslationResources = {
workspaces: "工作区",
agents: "Agents",
newAgent: "新建 Agent",
openProject: "打开项目",
addProject: "添加 project",
home: "首页",
},
},

View File

@@ -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<PickerSelection | null>(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("");

View File

@@ -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<string | null>(null);

View File

@@ -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<AddProjectFlowStoreState>((set) => ({
request: null,
open: (preferredHostId) => {
set({
request: {
id: nextRequestId++,
...(preferredHostId ? { preferredHostId } : {}),
},
});
},
close: () => set({ request: null }),
}));

View File

@@ -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<ProjectPickerStoreState>((set) => ({
request: null,
open: (serverId) => set({ request: { serverId } }),
close: () => set({ request: null }),
}));

View File

@@ -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();

View File

@@ -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<ProjectCreateDirectoryPayload> {
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<WorkspaceGithubSearchRepositoriesPayload> {
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,

View File

@@ -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");

View File

@@ -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();
});
});

View File

@@ -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<typeof FetchWorkspacesResponseMessageSchema>;
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
export type ProjectCreateDirectoryResponse = z.infer<typeof ProjectCreateDirectoryResponseSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type WorkspaceGithubSearchRepositoriesResponse = z.infer<
typeof WorkspaceGithubSearchRepositoriesResponseSchema
>;
export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
@@ -4778,6 +4873,11 @@ export type LegacyListAvailableEditorsRequest = z.infer<
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
export type ProjectCreateDirectoryRequest = z.infer<typeof ProjectCreateDirectoryRequestSchema>;
export type ProjectCreateDirectoryErrorCode = z.infer<typeof ProjectCreateDirectoryErrorCodeSchema>;
export type WorkspaceGithubSearchRepositoriesRequest = z.infer<
typeof WorkspaceGithubSearchRepositoriesRequestSchema
>;
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>;
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;

View File

@@ -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<string> {
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();
});
});

View File

@@ -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<void>;
rmdir(path: string): Promise<void>;
}
interface CreateProjectDirectoryDependencies {
filesystem?: ProjectDirectoryFileSystem;
registerProject(directoryPath: string): Promise<PersistedProjectRecord>;
}
const nodeProjectDirectoryFileSystem: ProjectDirectoryFileSystem = {
stat,
async mkdir(path) {
await mkdir(path);
},
rmdir,
};
export async function createProjectDirectory(
input: CreateProjectDirectoryInput,
dependencies: CreateProjectDirectoryDependencies,
): Promise<CreateProjectDirectoryResult> {
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<void> {
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);
}

View File

@@ -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<void>;
@@ -273,6 +278,7 @@ interface SessionForTestOptions {
github?: Partial<GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
workspaceGitService?: {
getCheckout?: ReturnType<typeof vi.fn>;
getCheckoutDiff?: ReturnType<typeof vi.fn>;
getSnapshot?: ReturnType<typeof vi.fn>;
suggestBranchesForCwd?: ReturnType<typeof vi.fn>;
@@ -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[] = [];

View File

@@ -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<SessionInboundMessage, { type: "project.create_directory.request" }>,
): Promise<void> {
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<void> {
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<SessionInboundMessage, { type: "workspace.github.clone.request" }>,
): Promise<void> {

View File

@@ -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,
},
};
}

View File

@@ -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" },
},
]);
});
});

View File

@@ -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<GitHubPullRequestTimeline>;
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
searchRepositories(options: SearchGitHubRepositoriesOptions): Promise<GitHubRepositorySummary[]>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
@@ -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<string>,
): Promise<GitHubCloneProtocol> {
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 || "{}")));
}